Skip to content

fix(core): preserve cells when structurally editing ragged GFM tables#127

Open
changdapeng wants to merge 1 commit into
floatboatai:mainfrom
changdapeng:fix/table-ragged-structural-edits
Open

fix(core): preserve cells when structurally editing ragged GFM tables#127
changdapeng wants to merge 1 commit into
floatboatai:mainfrom
changdapeng:fix/table-ragged-structural-edits

Conversation

@changdapeng

Copy link
Copy Markdown

Summary / 摘要

Structural table edits (column drag-reorder, column delete, Delete-key range clear) dropped cells and misaligned the header on GFM tables whose rows omit the leading/trailing pipe; rows are now parsed GFM-consistently and touched rows are re-serialized in canonical form.

Motivation / 背景与动机

GFM allows table rows to omit their leading and/or trailing pipe, and remark renders such "ragged" tables as normal table widgets. But moveColumn / deleteColumn / the range clear parsed rows with split("|") + slice/filter, which assumes canonical pipes:

  • on ragged rows the first/last cell was silently dropped;
  • moveColumn additionally skipped rows whose parsed cell count fell short of the drag index — data rows were reordered while the header stayed put, corrupting the table.

Minimal reproduction (drag the 年龄/职位 grips to swap the two columns):

姓名 | 年龄 | 职位  |  |
| --- | --- | ---  | --- |
| 张三 | 25 | 提示词工程师  |  |
| 李四 | 30 | 测开工程师  |  |
  • Issue: none filed — the defect and the expected deliverable ("真正修复表格列拖动问题的最小 PR,并配套少量稳定的 vitest/Playwright 回归用例") were described by the maintainer in the PR test(core): add table drag eval harness #102 review; the reproduction corpus from that PR is ported here as unit tests.
  • Roadmap (docs/ROADMAP.md): n/a (bug fix)
  • OpenSpec change: n/a (bug fix; the new module is internal and not exported from the package entry — no public API change)

Changes / 变更内容

  • packages/core:
    • New internal module src/table-markdown.ts — pure GFM table-source transforms: splitRowCells (strips at most one pipe per edge, honors \| escapes via a character scan), serializeRowCells (canonical leading+trailing-pipe form; no cell-content substitution, so pre-existing zero-width cells round-trip byte-identically), moveTableColumn / deleteTableColumn / appendTableColumn / clearTableCellRange, and tableColumnCount. Two deliberate semantics, documented in the module: the delimiter row is identified by POSITION (always the block's second line, per the GFM spec) — a dash-only body row like | - | - | is data and must never be treated as a delimiter — and the column count is the max over all rows, matching the widget's own max-colCount rendering in toDOM() (cells beyond the delimiter count are preserved, not dropped). Every row is padded to that width before editing, which removes the silent row-skip; out-of-range/negative indices return the source unchanged; alignment markers move with their column. Pure functions, no DOM/state, unit-testable in isolation.
    • src/live-preview-table.tsmoveColumn / deleteColumn / addColumn / the Delete-key range clear delegate to those functions (addColumn's old regex append misplaced the new cell on ragged rows and was escape-blind); addRow sizes the new row by tableColumnCount instead of the header's AST cell count; separator checks go through isSeparatorLine (the regex is module-private now). The widget interaction state machine (editing locks, rAF polling, drag lifecycle, dispatch ordering) is untouched.
  • Tests:
    • packages/core/test/table-markdown.test.ts — 39 cases: the test(core): add table drag eval harness #102 corpus ported as conservation assertions (row count, uniform column count, cell-multiset preservation, normalized pipes), alignment-marker follow, escaped pipes, out-of-range/negative-index no-op guards, byte-identical well-formed controls — including pre-existing zero-width cells (| a || c |), which round-trip untouched when a different column is edited — and dash-only body rows (| - |), which are treated as data positionally, never as a delimiter.
    • packages/core/test/live-preview-regressions.test.ts — 3 widget-level regressions driving the real grip-click + Delete event chain; assertions read editor.getDocument() only, no layout/coordinate dependency (jsdom reports zero layout, so coordinate-driven tests would be unreliable).

Testing / 测试

  • pnpm test passes — 557/557 (was 515 on main + 42 new)
  • Affected packages build (pnpm build for @floatboat/nexus-core)
  • pnpm typecheck (pnpm -r exec tsc --noEmit) — zero errors; git diff --check clean
  • Manual UI check in electron-demo (real browser layout, real mouse event chain): the reproduction table's 年龄/职位 drag swap, grip-select + Delete column removal, well-formed tables behaving byte-identically to before the fix, and adjacent-table isolation
  • Before/after screenshots of the reproduction table drag: attached as a follow-up comment

Compliance / 合规自检

  • CLA signed
  • AI disclosure: the functional code in this PR is not primarily generated by AI. AI assistance, if any, is described below.
    AI-assisted notes / AI 使用说明: The functional code in this PR was primarily developed by AI (Claude Code) under my direction; I reviewed it and take responsibility for every line.
  • New dependencies: none
  • No build artifacts committed
  • No secrets committed

Checklist / 自检清单

  • Title follows Conventional Commits
  • No public API change (internal module, package README untouched)
  • Touched live-preview-table.ts → walked through the 12 Table Widget rules: the diff is confined to three method bodies' string transforms + the SEPARATOR_RE import; no event chain / editing lock / rAF / drag-lifecycle code touched (rules 1, 8, 10, 11, 12 domains all untouched)
  • Not a new capability / breaking change → no OpenSpec proposal needed
  • In scope per GOVERNANCE §4 (core bug fix)

Known limitation (out of scope)

The wide-table corpus case (viewport narrower than the table; the drop target can't be reached while dragging) is a layout-dependent UX issue, not a source-transform defect — it can't be covered by stable DOM-only tests and is left as a follow-up.

Relationship to the stale-offsets PR

Textually this PR and the stale-offsets PR edit the same structural-edit method bodies, so whichever lands second needs a mechanical rebase: the union is simply passing the other PR's live source (this.currentTableSource()) as the transform input, e.g. this.dispatch(deleteTableColumn(this.currentTableSource(), colIdx)). The fixes are logically independent.

GFM lets table rows omit their leading/trailing pipe, and remark renders
such ragged tables as normal table widgets. The structural edits (column
drag reorder, column delete, range clear) parsed rows with
split("|") + slice/filter, which assumes canonical pipes: on ragged rows
they silently dropped the first/last cell and skipped rows whose parsed
cell count fell short of the drag index, reordering data rows while the
header stayed put — corrupting the table.

Extract the row parsing/serialization into pure functions
(table-markdown.ts) that strip at most one pipe per edge, honor \|
escapes, pad every row to the table's widest row before editing, and
re-serialize touched rows in canonical form. Well-formed tables
round-trip byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoRNuUB2DWz7csbpNvABnz
@CLAassistant

CLAassistant commented Jul 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@changdapeng

Copy link
Copy Markdown
Author

Before/after of the reproduction table (real browser, real mouse drag of the 年龄 grip onto 职位):

Before — ragged header (missing leading pipe) + trailing empty column, rendered as a 4-column widget:

before drag

After — columns swapped with every cell preserved; all rows normalized with leading/trailing pipes; the adjacent well-formed table byte-identical:

after drag

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants