feat(editor): multi-block selection & actions (single-root)#235
Open
JSv4 wants to merge 26 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the multi-block selection usability gap: the browser
DocxEditorcould only select and act on one block at a time. It is now a singlecontenteditableroot, 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
contenteditable.MergeParagraphssentence-space is removed at the join).Architecture (controlled single root)
contenteditablesurface (so a native selection spans blocks)data-editable="1"+tabindex="-1"(focusable, not editing hosts → no selection boundary)contenteditable="false"island with editable cell paragraphs → boundaryselectionchangecommits the block a collapsed caret leavesbeforeinput(editor-input.tsclassifier) → routed toDocxSessioneditor-selection.ts(SelectionModel: covered body blocks + clipped spans)parseEditrecords each mutation into agroup();undo/redoreverse/replay a whole groupNew modules:
npm/src/editor-selection.ts,npm/src/editor-input.ts. Full design:docs/architecture/multi_block_selection.md.Invariants preserved
{#kind:scope:unid}anchor scheme (data-editableis orthogonal addressing).RenderBlockHtml; structural ops remount, as before).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 thedata-editableselector; two that useddocument.activeElementas 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 --noEmitclean. The single failure iseditor-fontfamily.spec.ts, a pre-existing, environment-only failure: the C#FormatOp.FontFamilyengine support (from #234's4df175a) isn't compiled into the locally-serveddist/wasm, so the raw bridgeApplyFormat({fontFamily})doesn't render (verified directly) — unrelated to this npm-only change; it goes green on a networkednpm run build. .NET suite unaffected (branch touches zero.cs/.csproj).🤖 Generated with Claude Code