Skip to content

feat(editor): multi-block selection & actions (single-root)#235

Open
JSv4 wants to merge 26 commits into
feat/ir-editor-feasibility-pocfrom
feat/editor-multi-block-selection
Open

feat(editor): multi-block selection & actions (single-root)#235
JSv4 wants to merge 26 commits into
feat/ir-editor-feasibility-pocfrom
feat/editor-multi-block-selection

Conversation

@JSv4

@JSv4 JSv4 commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the multi-block selection usability gap: the browser DocxEditor could only select and act on one block at a time. It is now a single contenteditable root, so a real user can select across paragraphs/headings (mouse drag, Shift+Click, Shift+Arrow) and apply actions to the whole selection.

Stacked on #234 (targets feat/ir-editor-feasibility-poc). npm-only — no C#/WASM/Python changes.

What works

  • Reachable cross-block selection via real gestures (drag / Shift+Click / Shift+Arrow) — previously impossible because each block was its own contenteditable.
  • Ribbon actions across the selection: formatting (bold/italic/font/size/family), paragraph ops (alignment/indent/style/list).
  • Destructive cross-block edits: type-over, Backspace/Delete, Enter, and plain-text paste. A cross-block delete trims the first block, removes whole middle blocks, and merges the trimmed last block into the first with a seamless join (the MergeParagraphs sentence-space is removed at the join).
  • One atomic undo for every compound edit (client-side undo grouping — no engine change).
  • Tables are a selection boundary (v1): a selection reaching a table only acts on the body blocks; cell editing and the grid are untouched.

Architecture (controlled single root)

Concern How
Editing host one contenteditable surface (so a native selection spans blocks)
Body blocks data-editable="1" + tabindex="-1" (focusable, not editing hosts → no selection boundary)
Tables contenteditable="false" island with editable cell paragraphs → boundary
Keydown / blur root-level (focus stays on the host during editing)
Between-block commit selectionchange commits the block a collapsed caret leaves
Cross-block input intercepted in beforeinput (editor-input.ts classifier) → routed to DocxSession
Selection geometry editor-selection.ts (SelectionModel: covered body blocks + clipped spans)
Atomic undo parseEdit records each mutation into a group(); undo/redo reverse/replay a whole group

New modules: npm/src/editor-selection.ts, npm/src/editor-input.ts. Full design: docs/architecture/multi_block_selection.md.

Invariants preserved

  1. DocxSession is the model of record (all edits route through session ops by anchor).
  2. One {#kind:scope:unid} anchor scheme (data-editable is orthogonal addressing).
  3. Render is an incremental projection (typing keeps RenderBlockHtml; structural ops remount, as before).
  4. Untouched content stays byte-faithful on save (a test asserts outer paragraphs survive a cross-block delete).

Tests

New: editor-multiblock-reach (real drag / Shift+Click / Shift+Arrow), editor-multiblock-delete, editor-multiblock-edit (type-over / Enter / atomic-undo round-trip), editor-undo-group, editor-multiblock-table-boundary. Existing editor specs migrated to the data-editable selector; two that used document.activeElement as a block proxy now read the caret's block (single-root: the active element is the host).

Verification: full Playwright suite 239 passed / 1 failed; tsc --noEmit clean. The single failure is editor-fontfamily.spec.ts, a pre-existing, environment-only failure: the C# FormatOp.FontFamily engine support (from #234's 4df175a) isn't compiled into the locally-served dist/wasm, so the raw bridge ApplyFormat({fontFamily}) doesn't render (verified directly) — unrelated to this npm-only change; it goes green on a networked npm run build. .NET suite unaffected (branch touches zero .cs/.csproj).

🤖 Generated with Claude Code

JSv4 and others added 26 commits June 19, 2026 21:50
Move contenteditable to the surface so a native selection spans blocks; body
blocks become tabindex-focusable (not editing hosts) marked data-editable.
Keydown/blur are root-level (focus stays on the host during editing); between-
block commits come from selectionchange; the root blur ignores internal focus
shifts (relatedTarget inside the editor) so setting a selection doesn't spuriously
re-render the block. Migrate test selectors to data-editable and adapt two tests
that used document.activeElement as a block proxy (now the caret's block).

Real cross-block mouse-drag selection is now reachable (editor-multiblock-reach).
…ility

Extract readSelection/MultiBlockSelection into editor-selection.ts (covered body
blocks + per-block clipped spans + roles; tables excluded as a boundary). Rewire
selectedBlocks/selectionModel through it. Add shift+click and shift+ArrowDown
reachability tests alongside the mouse-drag one.
parseEdit now records each successful mutation into an undo group (ungrouped =
group of 1; inside group() they coalesce). Multi-block format/paragraph ops run
in one group(); undo/redo reverse/replay a whole group. Single-block undo
behavior is unchanged.
Add editor-input.ts (classifyBeforeInput → native | format | deleteSelection |
typeOver | splitAtSelection | paste | block). Wire onBeforeInput on the root:
single-block input stays native; structural/cross-block ops are intercepted.
Compound handlers are stubs here (implemented in the next two commits).
deleteSelection/deleteSelectionInner trim the first block's tail, delete whole
middle blocks, and merge the trimmed last block into the first — as one atomic
undo, caret at the join. MergeParagraphs inserts a sentence-joining space; we
remove it at the join offset so a delete-selection joins seamlessly
(Al+arlie -> Alarlie) while keeping the moved runs' formatting.
typeOverSelection/splitAtSelection/handlePaste collapse the multi-block selection
(deleteSelectionInner) then insert/split via the proven single-block native path,
all in one atomic undo. onKeydown lets Enter fall through to beforeinput when the
selection is multi-block (Backspace/Delete already do, via their collapsed-caret
guard) so the compound handlers run instead of splitting the active block.
Verify a selection spanning body paragraphs into a table only formats the body
blocks (cells excluded by the SelectionModel) and leaves the grid intact. Note
multi-block selection in the demo.
…rs don't corrupt the model

The single-contenteditable-root rearchitect wired the root's keydown/
beforeinput/blur listeners ONCE (guarded by a dataset flag), with the
closures bound to the FIRST editor instance. Because a document re-open
(the demo's New/Open, supported via DocxEditor.open(sameContainer, ...))
re-uses the same container -- and that container IS the contenteditable
host, so the flag survived innerHTML="" -- every 2nd+ instance skipped
re-wiring and close() never unwired. Its Enter/Backspace were then handled
by the first, now-closed instance (whose handlers early-return), so plain
Enter fell through to native contenteditable, which cloned the paragraph
along with its data-anchor. The DOM showed multiple blocks aliasing a
single session paragraph; the model never gained paragraphs and the next
remount (any multi-block op, list toggle, undo, pagination, or
save->reopen) collapsed the document -- silently and unrecoverably.

Fix: the root listeners are now stable bound instance fields, (re)attached
PER OPEN keyed on the element (a __docxEditorHandlers property + a
removeRootHandlers helper), de-duped on a continuous remount
(wiredRoot===root early return), and removed in close(). A re-open even
defensively drops a leftover handler set the element still carries (e.g. a
host that re-opened without closing). The editorRootWired dataset guard is
removed. editor.ts only -- no C#/WASM/Python change.

Test: editor-reopen-structural.spec.ts -- Enter splits in the SESSION
after close + re-open on the same container, and without closing the old
one (both received 1 paragraph before the fix).
…ection restore, toolbar)

Round-5 S-1 smoke test found four drafting-blockers when authoring a cover page
end-to-end through the editor; all fixed (npm-only, no C# change):

- Inline format (bold/italic/font/size) silently no-opped on a freshly-typed
  line, in both the multi-block (last line skipped) and single-block paths
  (font/size over a whole line, e.g. the company name). A line typed into an
  empty paragraph keeps the placeholder space in the DOM, so a span built from
  raw DOM offsets overshoots the trimmed run the engine holds and ApplyFormat
  silently no-ops. Map DOM->committed offsets (shared toCommittedOffset) and
  apply whole-block when the selection covers the block: blockSpanForSelection
  (multi-block) + new clampSpanToCommitted (single-block format/setFontSize/
  setFontFamily).
- editor.save() dropped in-progress text (most visibly table-cell text) because
  it serialized the session with no flush. It now calls commitAllDirty() first.
- A multi-block format collapsed the selection, so Bold-then-Center required a
  re-select. Both multi-block paths now restoreMultiBlockSelection after remount.
- Demo: the floating table toolbar overlapped and intercepted clicks on document
  content directly above a table. A geometry test now drops it below the table
  when the above-placement band intersects any editable block.

Also verified the round-5 "table row-insert not undoable" and "font-family
no-op" findings were a stale-WASM / mixed-undo-path artifact (InsertTableRow
snapshots for undo; font-family round-trips on a fresh build) via
regression-guard tests.

Tests: editor-r5-fixes.spec.ts (6) + editor-demo-table-toolbar.spec.ts. Full
Playwright suite 249/249 green; live GUI re-smoke + LibreOffice round-trip
consistent (serif font and L/R cells intact).
…omplex filings

Resolve findings from a complex-filing drafting smoke test.

Significant: inserted tables no longer come out in the blank-doc default
(Calibri) when the document body uses another font. New
TableInsertOptions.CellFontFamily stamps schema-ordered w:rFonts on seeded
runs and on each cell's paragraph-mark run properties (shared
SetRunFontInOrder helper); ApplyReplaceTextAccept carries a paragraph-mark
font onto fontless new runs so typing into an empty cell keeps the font (a
general fix for retyping any empty font-bearing paragraph, never overriding a
run's own font). DocxEditor.insertTable resolves the font from the active /
nearest non-empty block and passes it automatically. Rippled
DocxSession -> DocxSessionJson -> types.ts -> editor.ts. C# DS223-227 +
browser editor-table-font.spec.

Polish:
- insertTable gains a position ("above"|"below") so a table can sit above a
  non-empty block; the demo's Above/Below selector now drives rules and tables.
- Single-block format/font/size/alignment restore the selection after their
  re-render (restoreSingleBlockSelection) so commands chain without
  re-selecting; a collapsed caret in a cell + format covers the whole cell.
- Demo floating table toolbar no longer covers the editable line below a table
  (floats above/below when clear of real text, else overlays the table's own
  non-editable bottom); the collision check ignores empty lines.
- Corrected the demo cell help text (Enter stacks a line in a cell; Tab inert).

Full .NET 2233 and Playwright 255 green; Release build clean; live-verified.
Round-7 smoke test of the DocxEditor against a complex multi-column filing
cover flagged six issues; all fixed (TDD).

Added:
- DocxSession.InsertTab(anchor, offset, TabStopAlignment) + TabStopAlignment
  enum: a w:tab run + a w:tabs/w:tab stop (Right = section right content
  margin), de-duped/idempotent. Left+right text on one baseline without a
  two-column table. Rippled Ops/Json/WASM bridge/npm types+session+editor;
  demo Right-tab button. C# DS240-242, browser editor-tabstop.spec.
- SplitParagraph carries the split-point run rPr onto the new paragraph mark
  (generalized CarryMarkFormatToFreshRuns), so Enter keeps bold/italic/font/
  size. heading->Normal and HR-split exceptions still reset. C# DS230-231.
- Context-aware Ctrl+A: body selects body blocks (tables a boundary), in a
  table selects that table's cells (one font fonts the whole table).
  editor-select-all.spec.

Fixed:
- Table-cell text was silently erased: a cell is its own contenteditable host,
  so leaving it didn't commit; the next remount rebuilt it empty. Commit a
  cell on blur (scoped to cells). editor-cell-commit.spec.
- Floating table toolbar overlapped a short sandwiched table's own cells; it
  now docks to a side gutter as a vertical strip. editor-demo-table-toolbar.

.NET 2238 passed, Playwright 261 passed, Release clean, live demo smoke green.
Completes the InsertTab ripple for the complex-filing right tab stop.

- tools/python-host/Dispatcher.cs: "insert_tab" op + ParseTabAlignment helper
  (routes to DocxSessionOps.InsertTab via DocxSessionJson.ParseTabAlignment).
- docx-scalpel: TabStopAlignment enum (enums.py), DocxSession.insert_tab
  (session.py), exported from __init__.

End-to-end test python/tests/test_insert_tab.py (docx-scalpel → NDJSON →
docxodus-pyhost → engine): right tab stop + tab run land in the paragraph XML;
default alignment is Right. Full Python suite 45/45.

Note: the rest of the structural family (InsertHorizontalRule/InsertTable/etc.)
remains absent from docx-scalpel — it is a subset; InsertTab is the first of
that family wired through to Python.
…FormatOp

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wire shape

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…i-level scheme, deduped)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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