Skip to content

feat: cell merge (engine model + rendering, selection, context menu) - #148

Merged
righ merged 6 commits into
masterfrom
righ/feat-merge-cells
Oct 3, 2026
Merged

righ merged 6 commits into
masterfrom
righ/feat-merge-cells

Conversation

@righ

@righ righ commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds merged cells — the engine model plus rendering, selection, and context-menu operations in react-core.

Engine (@gridsheet/engine)

  • A merge is stored on its top-left anchor cell as merge: { rows, cols } (new MergeSpan type); covered cells are derived. Works from buildInitialCells too ({ B2: { merge: { rows: 2, cols: 3 } } }).
  • New Sheet API: merge({ area }), unmerge({ area }), getMerges(), getMergeAt(point), expandAreaByMerges(area).
  • merge keeps only the anchor's value; covered values are discarded (undoable). A partially overlapped merge is absorbed.
  • Structural ops keep merges coherent:
    • insert rows/cols strictly inside a merge grows it;
    • remove shrinks it (dissolves at 1×1); removing the anchor row/col hands the merge (and its layout) to the next surviving cell;
    • move carries a whole merge with its anchor (incl. cross-sheet);
    • sortRows refuses while any merge spans rows.
  • Everything rides the existing history (undo/redo).

UI (@gridsheet/react-core, @gridsheet/web styles)

  • Rendering: the host cell (the anchor, or the first rendered cell of the range when the anchor is scrolled out of the virtualized window) stretches its content over the whole range. Inner grid lines are painted with --gs-surface; the grid lines are the --gs-border backdrop showing through, so transparent would not hide them.
  • Selection: pointing a covered cell chooses the anchor; arrows step over a merge from its far edge; Enter/Tab walk over covered cells; drag / Shift selections grow to include every merge they touch. The editor covers the whole range.
  • Context menu: "Merge cells" / "Unmerge cells".

Copy / cut / paste

  • Copy-paste carries source merges to the destination, clipped to it (and tiled when the paste repeats). Values-only paste keeps the destination's layout and drops values that would land on covered cells.
  • A paste or cut-paste dissolves destination merges it cuts through (anchor outside the destination); a merge whose anchor is overwritten is replaced. A USER paste is refused when such a merge is Merge-protected.
  • Cut refuses a source that cuts through a merge (the UI never produces one, since selections grow over merges). Redo of a move re-applies the dissolved merges.
  • With no selection, copying a merge anchor copies the whole merge.

Prevention

  • New operations.Merge flag (262144), included in ReadOnly (so ReadOnly's numeric value changes).
  • A USER merge is refused when any cell of the merge-expanded area carries Merge, or when a Write-protected cell would lose its value. A USER unmerge is refused when the anchor carries Merge. SYSTEM (programmatic) calls are not restricted.
  • Sheet.canMerge(area) / canUnmerge(area) decide whether the context-menu items are enabled.

Also fixed

  • Editor debounced bare Shift/Alt keydowns (like any key), so the key they modify could be dropped when pressed within a frame — e.g. a quick Shift+Arrow right after a click selected nothing. Shift/Alt are now exempt, like Meta/Control.

Not in this PR

  • autofill across merges;
  • xlsx <mergeCells> import/export.

Test plan

  • Jest: packages/engine/src/merge.spec.ts (40 cases: merge/unmerge, undo/redo, formulas, insert/remove, sort, move incl. cross-sheet, prevention, copy/paste, cut/paste).
  • Playwright: e2e/merge.spec.ts (9 cases: rendering, pointing + arrows/Enter, Shift+arrow / drag / Tab, selection growth, context-menu merge/unmerge + undo/redo, scrolled-out anchor, Merge prevention, copy-paste, cut-paste) against Storybook Basic/Merge. The full e2e suite passed (99/99).
  • typecheck:all, react-core typecheck.
  • e2e/utils.ts: go() now honours STORYBOOK_URL, so the suite can run against a Storybook on a port other than 5233.

🤖 Generated with Claude Code

righ and others added 6 commits October 3, 2026 04:08
Store a merge as `merge: { rows, cols }` on its top-left (anchor) cell; covered
cells are derived. Adds Sheet.merge / unmerge / getMerges / getMergeAt /
expandAreaByMerges, keeps merges coherent across insert/remove rows & cols
(grow, shrink, hand the anchor to the next surviving cell), move (incl.
cross-sheet) and undo/redo, and refuses to sort while a merge spans rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Render: the host cell (anchor, or the first rendered cell when the anchor is
  scrolled out) stretches its content over the range; inner grid lines are
  painted with the cell surface.
- Selection: pointing a covered cell chooses the anchor, arrows/Enter/Tab step
  over merges, selections grow to include every merge they touch.
- Context menu: "Merge cells" / "Unmerge cells".
- Storybook Basic/Merge + e2e; e2e `go()` honours STORYBOOK_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New `operations.Merge` flag (262144), included in `ReadOnly`.
- USER merge is refused when any cell of the merge-expanded area carries
  Merge, or a Write-protected cell would lose its value; USER unmerge is
  refused when the anchor carries Merge. SYSTEM calls are unrestricted.
- `Sheet.canMerge(area)` / `canUnmerge(area)` drive the context-menu state.
- Init-time (column/row default) prevention is read without populating cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor drops keydowns that arrive within a frame of the previous one.
Shift/Alt counted as such a keydown, so the key they modify could be
swallowed when pressed quickly after them (e.g. Shift+Arrow right after a
click). Exempt them like Meta/Control. Fixes the CI-only failure of the
merge Shift+Arrow e2e, which no longer needs a workaround.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Copy-paste carries source merges, clipped to the destination (and tiled
  when it repeats). Values-only paste keeps the destination layout and
  drops values that would land on covered cells.
- A paste or cut-paste dissolves destination merges it cuts through
  (anchor outside the destination); a merge whose anchor is overwritten is
  replaced. Refused (USER) when such a merge is Merge-protected.
- Cut refuses a source that cuts through a merge; redo of a move re-applies
  the dissolved merges.
- With no selection, copying a merge anchor copies the whole merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@righ
righ merged commit 3b5cf10 into master Oct 3, 2026
4 checks passed
@righ
righ deleted the righ/feat-merge-cells branch October 3, 2026 12:43
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.

1 participant