From daa497f1d0a085dbf080fc331aaf352bccd4457d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 11 Jul 2026 08:16:40 +0300 Subject: [PATCH 1/2] Add region-scoped select-all and cross-host selection delete Two-stage Ctrl/Cmd+A (Docs-style): native in-host select-all is stage 1; a fully-selected or empty host escalates to a document-level range over all blocks of the region. Ctrl+A away from a caret - body focus, fresh load, a focused image island - selects the active region too; native page select-all only when no editable region exists. One block-granular delete machine (select.xsl) serves stage-2 selections and mouse sweeps alike, from host or body focus: fully covered blocks are removed whole, partial edge hosts get host-scoped sub-range deletes, composites holding a range boundary keep their grid (covered cells cleared, flow cells collapse back to text hosts), non-composite edge remnants merge Docs-style with the caret at the seam, emptied containers are pruned, chrome is re-injected and an emptied region is reseeded with a fresh paragraph. The range clamps to a single region - one gesture, one region-keyed undo entry. Typing, Enter, Tab and paste over a cross-host selection are suppressed; copy and plain arrows stay native. New browser suite select.mjs covers both Ctrl+A routes, sweep deletes, merge/composite/clamp semantics and the I1-I5 invariants with exact one-undo baseline restores after every mutating case. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- src/edit.xsl | 92 +++++++++- src/index.xsl | 2 + src/select.xsl | 366 +++++++++++++++++++++++++++++++++++++ tests/browser/run.mjs | 2 +- tests/browser/select.mjs | 376 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 834 insertions(+), 7 deletions(-) create mode 100644 src/select.xsl create mode 100644 tests/browser/select.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 89c6e33..0390933 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Modules under `src/`: - **annotate.xsl** — the unified right-click dispatcher (edit vs create), `local:apply-annotation` (single write path for RDFa attributes), shared `local:wrap-range`/`local:unwrap-element` (used by annotations AND inline formatting), invalid-selection flash, the shared output modal (`local:show-output`). - **edit.xsl** — the XHTML editor. **Recursive editability init** (`local:init-block`): an element is a *text host* (contenteditable) while its content model allows text and it holds no block children; mixed flow containers (`li`/`td`/`dd`/… with blocks) and structural containers (blockquote, lists, figure, table) lock their own markup and recurse into their parts (`figure` is always composite: image island + caption host); `img` never editable but given `tabindex="-1"` so a block image is a focusable **navigation island**. **Run wrappers**: the stray inline runs of a mixed flow container (`
  • text
  • `) get an ephemeral `p.rdfa-editor-run` host (`local:wrap-stray-runs`) so they stay editable — unwrapped again at canonicalization (C11), so mixed content round-trips byte-identical; structural gestures promote a wrapper to a real paragraph. **Load-init normalization**: each region is probed for invalid nesting (`cm:valid-nesting`, stray text in structural containers, stray region-level inline) and rebuilt through `mode="cm-normalize"` only when invalid (the valid case is zero-churn; host-page node references into a rewritten region go stale, same as undo restore). First-child chrome injection is a **top-level-only** affordance (`span[@data-role='chrome']`, hover-revealed via CSS) — nested hosts never carry drag handles. The Enter/Backspace state machine (split/merge primitives with explicit caret placement): splitting a run wrapper *promotes* it (marker class off); E4a — Enter on the empty last item of a *nested* list outdents one level (progressive), E4b — top-level exits to a paragraph; B2 merges text blocks, B2b descends into a preceding non-composite container's (blockquote/list) last host — bare text in the container itself would be invalid — while tables/figures stay hard boundaries (B3); B4 merges an `li` into the *visually preceding line* (`local:merge-host-in` — the last host of the previous item, unless it sits inside a nested table/figure: composites are hard boundaries at any depth, the gesture stays inert), B4b outdents the first item of a nested list, B4c merges the first item of a container-nested list (cell, quote, dd) into the preceding line inside the container — dissolving the emptied list and collapsing the container back to a text host — B7 exits the first block of a blockquote upward (emptied quote removed); E4b anchors the list-exit paragraph *before* removal, so it lands where the list was (inside the cell/quote for nested lists); E6 anchors the caption-exit paragraph after the figure/table itself (which may be nested). **Ancestor walks clamp at the region root** (`[exists(local:block-of(.))]` on `local:item-of`, the Tab cell lookup and the quote-toggle's blockquote lookup) — a host-page li/td/blockquote wrapping an embedded region is never touched. A focused image island deletes its *figure* when it has one, else just the image — never the whole containing list/table. **Tab/Shift+Tab** indent/outdent list items (`local:list-indent`/`local:list-outdent`: indent moves the item into a trailing-or-fresh nested list *inside the previous item* per `ul → (li)+`; outdent moves it after the container item, demoting followers into a nested list inside it; `local:collapse-container` reverts an emptied container to a text host; extremes flash) — the innermost context wins over table-cell traversal (li-in-td indents, a table nested in an item traverses). The **quote toggle** (`format-quote`) wraps the current host block in a `blockquote` where the parent admits one / unwraps all children out (refused with a flash when the quote carries RDFa — triples would drop); the block-type select is **host-based** (`local:current-host`): a paragraph inside a quote converts alone, non-convertible hosts disable it. The **insert buttons** (+¶, lists) place the new block per the content model (`local:insert-block-at-caret`): after the caret's host when its parent admits the kind, *inside* a leaf flow host otherwise (a list inserted in a cell nests in the cell; in a list item it becomes a sublist), after the top-level block as a last resort. **Paste** is cm-driven: clipboard HTML runs `mode="canonical"` + `mode="cm-normalize"`, then blocks land *inside* a mixed flow host (`local:paste-into-flow-host` — head/tail runs wrapped, host re-inited as container), or as siblings of an inline-only host via split-and-thread (when `cm:allows-child` permits), or flatten to text (caption, dt). Drag-and-drop ported from LinkedDataHub's `client/block.xsl` (handle-gated `draggable`, midpoint before/after marks). Arrow-key block crossing walks `local:nav-targets` (editable hosts **plus** block images, in document order): a host gets a caret, an image is *selected* (`local:select-image` focuses it, clearing the caret) — so images are never skipped; on a selected image, arrows step to the adjacent target and Backspace/Delete removes the whole figure (no confirm, undo-covered). Table cell keys (Tab/Enter) and the table dialog live in **tables.xsl**. - **tables.xsl** — table blocks as a composite kind: the insert dialog (rows×cols + header-row + caption, modeled on the figure dialog), positional row/column operations (`local:op-insert-row`/`-column`, `-delete-row`/`-column`) gated behind `local:has-spans` (disabled on pasted `colspan`/`rowspan` grids — positional edits would corrupt a spanned table) and toolbar-synced via `local:sync-table-toolbar` riding `local:update-breadcrumb`, plus `local:table-tab`/`local:table-enter` cell traversal that appends a body row at the bottom edge. Cells are `%Flow;` containers: every caret landing resolves through `local:first-host-in`/`local:last-host-in`, so a cell holding blocks lands the caret in its first/last editable host; the Tab dispatcher (edit.xsl) passes the *cell* as the traversal host even when the caret sits in a nested block. Deleting the last body row/column is a no-op — the confirm-guarded delete-block button removes the whole table. **Undo hazard:** a chrome `span` serialized as a direct child of `` is foster-parented out by the HTML fragment parser on `innerHTML` restore, so `local:restore-snapshot` drops stray region-child chrome and re-injects (idempotent) before caret resolution. +- **select.xsl** — region-scoped select-all and cross-host selection delete (dispatch lives in edit.xsl's keydown/body/paste templates, mirroring the tables.xsl split). **Two-stage Ctrl/Cmd+A** (Docs-style): stage 1 stays native — the browser scopes select-all to the focused host; stage 2 (host already fully selected per the chrome-aware `local:at-*` probes, or empty, or the selection already spans hosts) selects all blocks of the region as a **document-level range** (`local:select-region`) — it paints natively across host boundaries and never reaches the host page. Ctrl+A away from a caret also selects editor content, never the page: from the body it targets the swept region, else `local:active-root()` (selection anchor → last focused host → first region), and a focused image island is its own fully-selected unit (stage 2 directly, with Backspace/Delete then running the delete machine instead of the island's figure-delete); native page select-all happens only when no region exists. One **delete machine** (`local:delete-cross-host-selection`) serves stage-2 selections and mouse sweeps alike (`local:selection-crosses-hosts()` gates it), fired by Backspace/Delete from host *or* body focus — a sweep from the page background leaves focus on body. Deletion is **block-granular**, never one raw `deleteContents` across the range: fully covered blocks are removed whole; partial edge hosts get sub-range deletes scoped *inside* the host; composites holding a range boundary never lose structure — their covered cells are cleared and flow cells collapse back to text hosts (`local:clear-host`; B3/B4 doctrine at partial coverage) while a *fully* covered composite is removed whole; non-composite edge remnants merge Docs-style via `local:merge-into-previous` with the caret at the seam (never with `pre` — B6); emptied structural containers are pruned (`local:prune-husks`), chrome is re-injected (idempotent), and an emptied region is reseeded with a fresh `p` host (`local:seed-region`). The range is **clamped to a single region** (the start's, else the first swept) — one gesture, one region-keyed undo entry; other regions stay byte-identical. Typing/Enter/Tab/paste over a cross-host selection are suppressed (type-to-replace is future work); Ctrl+C and plain arrows stay native. - **undo.xsl** — unified snapshot undo/redo over `#content` innerHTML: every mutating handler calls `local:push-undo` first and `local:after-mutation` last; plain typing coalesces into ~1s bursts via `ixsl:onbeforeinput`. Ctrl/Cmd+Z / Shift+Z / Ctrl+Y are intercepted (native undo is replaced). Stacks live in a hidden DOM stash (`#undo-storage`, `data-role="storage"`) — JS arrays don't survive the IXSL boundary and sequence-valued window properties keep only their first item. Caret restoration after undo is approximate (first host) by design. - **navigate.xsl** — FontoXML-inspired navigation: ToC drawer (recursive `for-each-group group-starting-with` outline, click-to-jump, section drag-reorder via `local:section-of`), breadcrumb bar (element path + `rdfa:in-scope-subject` at the caret), lint surfacing (markers + badge + issues modal), find & replace (single-text-node matches, `nodeValue`-rewrite replace-all — annotation-safe by construction). - **lint-rdfa.xsl** — RDFa lint logic. **Pure XSLT**, tested headless via `tests/lint-driver.xsl` (`xsl:import` resolves the output-method conflict). Reuses the extractor's resolution functions so lint verdicts can't drift from extraction semantics. Checks: term-unresolvable, empty-href, content-resource-conflict, empty-literal, about-relative. @@ -65,6 +66,6 @@ Run `make sef` after any XSLT change; run the tests after any extractor change. - **Undo contract**: every mutating handler pushes a snapshot first (`local:push-undo`, optionally with a pre-captured `$snapshot` when the operation can fail) and calls `local:after-mutation` last — never push from shared primitives. Undo/redo restore invalidates all node-valued window properties (cleared in `local:restore-snapshot`) and closes overlay/dialogs. The caret rides along on stash entries as (block, text-node, offset) data attributes and is restored on undo/redo. - **Content markers**: anything written into `#content` for UI purposes must be `@class` (or `aria-*`) only — both are canonicalization-stripped. `@title` is NOT stripped and is forbidden as a marker. - **SaxonJS gotchas**: computed numeric predicates (`[xs:integer(...)]`) are evaluated as booleans in some contexts — bind to a variable and use the bare `[$index]` form. JS arrays returned by `ixsl:call` marshal to XDM sequences (empty array = empty sequence). Large nested map literals fail the compiler with an internal assertion — build them with `map:merge` over `map:entry` (see `$cm:model`). -- Browser tests live in `tests/browser/` (`npm run test:browser` self-serves the repo): `editor.mjs`, `features.mjs`, `fixes.mjs`, `hardening.mjs`, `multiinstance.mjs`, `tables.mjs`, `datatype.mjs`, `inspector.mjs`, `nesting.mjs` (content-model foundation), `authoring.mjs` (nesting gestures) and `invariants.mjs` (the cross-product net: a uniform gesture battery in every caret context asserting properties that must always hold — no orphan text outside an editable host, no nested hosts, chrome top-level only, run wrappers editable, zero lint issues, undo restores the exact baseline), all against `tests/fixture-nesting.html`. Scenario tests assert *semantics* (where things land); the invariant suite catches *validity/editability/undo* regressions in combinations nobody enumerated. CI runs both headless loops and the browser suites (`.github/workflows/ci.yml`). +- Browser tests live in `tests/browser/` (`npm run test:browser` self-serves the repo): `editor.mjs`, `features.mjs`, `fixes.mjs`, `hardening.mjs`, `multiinstance.mjs`, `tables.mjs`, `datatype.mjs`, `inspector.mjs`, `nesting.mjs` (content-model foundation), `authoring.mjs` (nesting gestures), `select.mjs` (two-stage Ctrl+A, cross-host sweep delete, composite/clamp semantics) and `invariants.mjs` (the cross-product net: a uniform gesture battery in every caret context asserting properties that must always hold — no orphan text outside an editable host, no nested hosts, chrome top-level only, run wrappers editable, zero lint issues, undo restores the exact baseline), all against `tests/fixture-nesting.html`. Scenario tests assert *semantics* (where things land); the invariant suite catches *validity/editability/undo* regressions in combinations nobody enumerated. CI runs both headless loops and the browser suites (`.github/workflows/ci.yml`). - **Sanitization**: `canonical-xhtml.xsl` is the storage boundary — it drops script/style/iframe/object/embed/applet/form controls/link/meta/base subtrees, comments/PIs, all `on*` attributes, and `javascript:`/`vbscript:`/`data:` URLs (`data:image/*` allowed in `@src`). HTML paste goes through this same mode. Lint mirrors these as `unsafe-attribute`/`unsafe-url`. - Editor-contract CSS lives in `rdfa-editor.css` (host pages include it; the container needs ~2.5em left padding for the gutter handles); `index.html` keeps only demo styles. diff --git a/src/edit.xsl b/src/edit.xsl index 1ecb9d4..d06611d 100644 --- a/src/edit.xsl +++ b/src/edit.xsl @@ -553,6 +553,22 @@ version="3.0"> + + + + + + + + + + + + + + + + + + + + + + + + @@ -678,23 +711,50 @@ version="3.0"> - + - + - + - + + + + + + + + + + + + + + + + @@ -707,6 +767,19 @@ version="3.0"> + + + + + + + + + @@ -734,6 +807,12 @@ version="3.0"> undo covers accidents); a bare island in a mixed container is deleted alone, never its whole list/table. The caret lands on the previous target for Backspace, the next for Delete --> + + + + + + + diff --git a/src/index.xsl b/src/index.xsl index 1c8955b..1f2295c 100644 --- a/src/index.xsl +++ b/src/index.xsl @@ -23,6 +23,7 @@ version="3.0"> - annotate.xsl right-click dispatch, apply/remove annotation, extraction modal - edit.xsl XHTML editing: blocks, keyboard, toolbar, dialogs, drag-and-drop - tables.xsl table blocks: insert dialog, row/column operations, cell traversal + - select.xsl region-scoped select-all and cross-host selection delete --> @@ -36,6 +37,7 @@ version="3.0"> + diff --git a/src/select.xsl b/src/select.xsl new file mode 100644 index 0000000..45ea91a --- /dev/null +++ b/src/select.xsl @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/browser/run.mjs b/tests/browser/run.mjs index c02c694..16bd8f0 100644 --- a/tests/browser/run.mjs +++ b/tests/browser/run.mjs @@ -25,7 +25,7 @@ const base = `http://localhost:${server.address().port}`; console.log(`serving ${root} at ${base}`); let failed = 0; -for (const suite of ['editor', 'features', 'fixes', 'hardening', 'multiinstance', 'tables', 'datatype', 'inspector', 'nesting', 'authoring', 'invariants']) { +for (const suite of ['editor', 'features', 'fixes', 'hardening', 'multiinstance', 'tables', 'datatype', 'inspector', 'nesting', 'authoring', 'invariants', 'select']) { console.log(`\n=== ${suite} ===`); const code = await new Promise(resolve => { const child = spawn(process.execPath, [fileURLToPath(new URL(`${suite}.mjs`, import.meta.url))], diff --git a/tests/browser/select.mjs b/tests/browser/select.mjs new file mode 100644 index 0000000..2cbfec0 --- /dev/null +++ b/tests/browser/select.mjs @@ -0,0 +1,376 @@ +// Region-scoped select-all and cross-host selection delete: two-stage Ctrl/Cmd+A +// (native in-host stage 1, whole-region stage 2, never the host page), one delete +// machine for stage-2 and mouse-sweep selections (Backspace/Delete from a host or +// from body), Google-Docs edge-remnant merge, composite (table/figure) grid +// preservation under partial sweeps, cross-region clamping, gesture suppression +// (typing/Enter/paste inert over a cross-host selection), and the I1-I5 invariants +// plus exact one-undo baseline restore after every mutating case +// (fixture-nesting.html). +import { chromium } from 'playwright'; + +const BASE = process.env.BASE_URL ?? 'http://localhost:8080'; + +const errors = []; +const results = {}; +const browser = await chromium.launch(); +const page = await browser.newPage(); +page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); }); +page.on('pageerror', err => errors.push(String(err))); +page.on('dialog', d => d.accept()); + +// stage 1 relies on the browser's NATIVE select-all, which follows the platform +// modifier (CI is Linux; local macOS needs Meta) - the dispatcher accepts both +const selectAllKey = (process.platform === 'darwin' ? 'Meta' : 'Control') + '+a'; +const undoKey = 'Control+z'; + +const load = async () => { + await page.goto(BASE + '/tests/fixture-nesting.html'); + await page.waitForSelector('#content > * > [data-role=chrome]', { state: 'attached', timeout: 15000 }) + .catch(() => errors.push('chrome never injected')); +}; +// caret inside the editable host whose direct text carries the needle +const caretIn = (needle, offset = 2) => page.evaluate(([txt, off]) => { + const host = [...document.querySelectorAll('#content [contenteditable=true]')] + .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(txt))); + if (!host) return false; + host.focus(); + const t = [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(txt)); + window.getSelection().collapse(t, off === -1 ? t.textContent.length : off); + return true; +}, [needle, offset]); +// a document-level range between two text offsets, located by needle (any region) +const sweep = (fromNeedle, fromOffset, toNeedle, toOffset) => page.evaluate(([fn, fo, tn, to]) => { + const textIn = needle => { + const host = [...document.querySelectorAll('[contenteditable=true]')] + .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(needle))); + return [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(needle)); + }; + const r = document.createRange(); + r.setStart(textIn(fn), fo); + r.setEnd(textIn(tn), to); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(r); + return sel.toString().length > 0; +}, [fromNeedle, fromOffset, toNeedle, toOffset]); + +// the I1-I5 net from invariants.mjs +const invariants = () => page.evaluate(() => { + const v = []; + const region = document.getElementById('content'); + const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT); + for (let n; (n = walker.nextNode());) { + if (!n.textContent.trim()) continue; + if (n.parentElement.closest('[data-role]')) continue; + if (!n.parentElement.closest('[contenteditable=true]')) + v.push('orphan text: "' + n.textContent.trim().slice(0, 30) + '"'); + } + for (const h of region.querySelectorAll('[contenteditable=true] [contenteditable=true]')) + v.push('nested host: ' + h.tagName.toLowerCase()); + for (const c of region.querySelectorAll('[data-role=chrome]')) + if (c.parentElement.parentElement !== region) + v.push('nested chrome in: ' + c.parentElement.tagName.toLowerCase()); + for (const r of region.querySelectorAll('.rdfa-editor-run')) { + if (r.tagName !== 'P') v.push('run wrapper is ' + r.tagName.toLowerCase()); + if (r.getAttribute('contenteditable') !== 'true') v.push('uneditable run wrapper'); + if (r.parentElement === region) v.push('top-level run wrapper'); + } + if (document.getElementById('lint-badge').style.display !== 'none') + v.push('lint: ' + document.getElementById('lint-badge').textContent); + return v; +}); +const baselineOf = () => page.evaluate(() => document.getElementById('content').innerHTML); +// every mutating case must satisfy the invariants and unwind with ONE undo +const settle = async (name, baseline) => { + const v = await invariants(); + if (v.length) errors.push(`${name} invariants: ${v.join('; ')}`); + await page.keyboard.press(undoKey); + return page.evaluate(html => document.getElementById('content').innerHTML === html, baseline); +}; + +// ==== A. two-stage Ctrl/Cmd+A ======================================================= +await load(); +await caretIn('Intro paragraph', 3); +await page.keyboard.press(selectAllKey); +results.stage1 = await page.evaluate(() => { + const sel = window.getSelection(); + const r = sel.getRangeAt(0); + const host = n => (n.nodeType === 3 ? n.parentElement : n).closest('[contenteditable=true]'); + return { + selectsHostText: sel.toString() === 'Intro paragraph.', + confined: host(r.startContainer) === host(r.endContainer), + }; +}); + +await page.keyboard.press(selectAllKey); +results.stage2 = await page.evaluate(() => { + const sel = window.getSelection(); + const r = sel.getRangeAt(0); + const region = document.getElementById('content'); + return { + regionLevel: r.startContainer === region && r.endContainer === region, + coversFirst: sel.toString().includes('Nesting demo'), + coversLast: sel.toString().includes('caption for gestures'), + excludesEmbedded: !sel.toString().includes('Embedded paragraph'), + excludesHostPage: !sel.toString().includes('host item before'), + }; +}); + +await page.keyboard.press(selectAllKey); +results.stage2Idempotent = await page.evaluate(() => { + const r = window.getSelection().getRangeAt(0); + const region = document.getElementById('content'); + return { still: r.startContainer === region && r.endContainer === region }; +}); + +// an empty host escalates on the first press (nothing to select in-block) +await load(); +await caretIn('Intro paragraph', 3); +await page.keyboard.press(selectAllKey); +await page.keyboard.press('Backspace'); // within-host full selection: native delete empties the host +await page.keyboard.press(selectAllKey); +results.emptyHostEscalates = await page.evaluate(() => { + const r = window.getSelection().getRangeAt(0); + const region = document.getElementById('content'); + return { regionLevel: r.startContainer === region && r.endContainer === region }; +}); + +// ==== B. stage 2 + Backspace empties and reseeds the region ========================= +await load(); +const baselineB = await baselineOf(); +await caretIn('Intro paragraph', 3); +await page.keyboard.press(selectAllKey); +await page.keyboard.press(selectAllKey); +await page.keyboard.press('Backspace'); +results.deleteAll = await page.evaluate(() => { + const region = document.getElementById('content'); + const blocks = [...region.children].filter(b => !b.hasAttribute('data-role')); + const sel = window.getSelection(); + return { + seeded: blocks.length === 1 && blocks[0].tagName === 'P' + && blocks[0].getAttribute('contenteditable') === 'true' + && !!blocks[0].querySelector(':scope > [data-role=chrome]') + && !!blocks[0].querySelector(':scope > br'), + caretInSeed: sel.rangeCount === 1 && sel.isCollapsed && blocks[0].contains(sel.anchorNode), + embeddedIntact: document.getElementById('embedded').textContent.includes('Embedded paragraph'), + }; +}); +results.deleteAll.undoRestores = await settle('deleteAll', baselineB); + +// ==== C. sweep delete with edge-remnant merge (host-focus route) ==================== +await load(); +const baselineC = await baselineOf(); +await sweep('Intro paragraph', 6, 'Plain item', 6); // "Intro |paragraph." -> "Plain |item" +await page.keyboard.press('Backspace'); +results.sweepMerge = await page.evaluate(() => { + const region = document.getElementById('content'); + const p = [...region.querySelectorAll(':scope > p')].find(x => x.textContent.includes('Intro')); + const items = [...region.querySelector(':scope > ul').children].filter(c => c.tagName === 'LI'); + const sel = window.getSelection(); + return { + merged: !!p && p.textContent.replace(/⠿/g, '') === 'Intro item', + mergedItemGone: items.length === 2 && items[0].textContent.includes('Mixed item'), + caretAtSeam: sel.isCollapsed && sel.anchorNode.textContent === 'item' && sel.anchorOffset === 0, + }; +}); +results.sweepMerge.undoRestores = await settle('sweepMerge', baselineC); + +// Delete-key parity +await load(); +await sweep('Intro paragraph', 6, 'Plain item', 6); +await page.keyboard.press('Delete'); +results.deleteKeyParity = await page.evaluate(() => { + const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro')); + return { merged: !!p && p.textContent.replace(/⠿/g, '') === 'Intro item' }; +}); +results.deleteKeyParity.undoRestores = await settle('deleteKeyParity', baselineC); + +// same-list li -> li: items merge, the nested list keeps one item +await load(); +const baselineC2 = await baselineOf(); +await sweep('Sub one', 5, 'Sub two', 4); +await page.keyboard.press('Backspace'); +results.sameListMerge = await page.evaluate(() => { + const nested = document.querySelector('#content > ul > li > ul'); + const items = nested ? [...nested.children].filter(c => c.tagName === 'LI') : []; + return { oneItem: items.length === 1, mergedText: items[0]?.textContent === 'Sub otwo' }; +}); +results.sameListMerge.undoRestores = await settle('sameListMerge', baselineC2); + +// ==== D. body-focus route: region-level sweep, whole blocks removed ================ +await load(); +const baselineD = await baselineOf(); +await page.evaluate(() => { + if (document.activeElement) document.activeElement.blur(); + const region = document.getElementById('content'); + const kids = [...region.childNodes]; + const p = [...region.children].find(b => b.textContent.includes('Intro paragraph')); + const ul = [...region.children].find(b => b.tagName === 'UL'); + const r = document.createRange(); + r.setStart(region, kids.indexOf(p)); + r.setEnd(region, kids.indexOf(ul) + 1); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(r); +}); +results.bodyRoute = { bodyFocused: await page.evaluate(() => document.activeElement === document.body) }; +await page.keyboard.press('Backspace'); +Object.assign(results.bodyRoute, await page.evaluate(() => { + const region = document.getElementById('content'); + const sel = window.getSelection(); + return { + blocksGone: !region.textContent.includes('Intro paragraph') && !region.textContent.includes('Plain item'), + neighborsIntact: region.textContent.includes('Nesting demo') + && region.textContent.includes('Bare quote text'), + caretLanded: sel.rangeCount === 1 && sel.isCollapsed && region.contains(sel.anchorNode), + }; +})); +results.bodyRoute.undoRestores = await settle('bodyRoute', baselineD); + +// ==== E. composites: partial sweeps clear cells, never the grid ==================== +// "af|ter" paragraph -> "Plain |cell": the container cell (Cell para + cell item) +// is fully covered - cleared and collapsed to a text host; the boundary cell keeps +// its remnant; the grid survives; no merge across the table +await load(); +const baselineE = await baselineOf(); +await sweep('after', 2, 'Plain cell', 6); +await page.keyboard.press('Backspace'); +results.compositePartial = await page.evaluate(() => { + const region = document.getElementById('content'); + const table = region.querySelector(':scope > table'); + const cells = table ? [...table.querySelectorAll('td')] : []; + const headP = [...region.querySelectorAll(':scope > p')].find(x => x.textContent.replace(/⠿/g, '') === 'af'); + const sel = window.getSelection(); + return { + gridIntact: !!table && cells.length === 2 && !!table.querySelector('tbody > tr'), + containerCellCollapsed: cells[0]?.getAttribute('contenteditable') === 'true' + && !cells[0]?.querySelector('p, ul') && !!cells[0]?.querySelector('br'), + boundaryCellRemnant: cells[1]?.textContent === 'cell', + headRemnantKept: !!headP, + noMerge: !headP?.textContent.includes('cell'), + caretAtHeadEnd: sel.isCollapsed && !!headP && headP.contains(sel.anchorNode), + }; +}); +results.compositePartial.undoRestores = await settle('compositePartial', baselineE); + +// fully covered composite goes whole; a boundary inside the NEXT composite only +// clears content: "Quote |in item" -> "A caption |for gestures" removes the table +// but keeps figure, image and the caption remnant +await load(); +const baselineE2 = await baselineOf(); +await sweep('Quote in item', 6, 'A caption for gestures', 10); +await page.keyboard.press('Backspace'); +results.compositeFull = await page.evaluate(() => { + const region = document.getElementById('content'); + const figure = region.querySelector(':scope > figure'); + const quoteP = [...region.querySelectorAll('li blockquote p')] + .find(x => x.textContent.replace(/⠿/g, '').startsWith('Quote')); + return { + tableGone: !region.querySelector(':scope > table'), + middleBlocksGone: !region.textContent.includes('Bare quote text') + && !region.textContent.includes('parser-fostered'), + figureKept: !!figure && !!figure.querySelector('img'), + captionRemnant: figure?.querySelector('figcaption')?.textContent === 'for gestures', + headRemnant: quoteP?.textContent.replace(/⠿/g, '') === 'Quote ', + noMergeAcrossFigure: !quoteP?.textContent.includes('for gestures'), + }; +}); +results.compositeFull.undoRestores = await settle('compositeFull', baselineE2); + +// ==== F. cross-region clamp: a sweep leaking into another region =================== +await load(); +const baselineF = await baselineOf(); +const embeddedBefore = await page.evaluate(() => document.getElementById('embedded').innerHTML); +await sweep('A caption for gestures', 2, 'Embedded quote', 6); +await page.keyboard.press('Backspace'); +results.crossRegionClamp = await page.evaluate(html => { + const region = document.getElementById('content'); + const figure = region.querySelector(':scope > figure'); + return { + embeddedByteIdentical: document.getElementById('embedded').innerHTML === html, + captionTruncated: figure?.querySelector('figcaption')?.textContent === 'A ', + imageKept: !!figure?.querySelector('img'), + }; +}, embeddedBefore); +results.crossRegionClamp.undoRestores = await settle('crossRegionClamp', baselineF); + +// ==== G. suppression: only Backspace/Delete act on a cross-host selection ========== +await load(); +await caretIn('Intro paragraph', 3); +await page.keyboard.press(selectAllKey); +await page.keyboard.press(selectAllKey); +const beforeSuppress = await baselineOf(); +await page.keyboard.type('x'); +await page.keyboard.press('Enter'); +await page.keyboard.press('Tab'); +await page.evaluate(() => { + const dt = new DataTransfer(); + dt.setData('text/html', '

    pasted

    '); + dt.setData('text/plain', 'pasted'); + document.querySelector('#content [contenteditable=true]').dispatchEvent( + new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); +}); +results.suppression = { + inert: await page.evaluate(html => document.getElementById('content').innerHTML === html, beforeSuppress), + selectionSurvives: await page.evaluate(() => { + const r = window.getSelection().getRangeAt(0); + return r.startContainer === document.getElementById('content'); + }), +}; + +// ==== H. Ctrl+A away from any caret selects the editor, never the page ============= +// fresh load, focus on body, no selection: the first region is selected +await load(); +await page.evaluate(() => { + if (document.activeElement) document.activeElement.blur(); + window.getSelection().removeAllRanges(); +}); +await page.keyboard.press(selectAllKey); +results.bodyCtrlA = await page.evaluate(() => { + const sel = window.getSelection(); + const r = sel.getRangeAt(0); + const region = document.getElementById('content'); + return { + selectsFirstRegion: r.startContainer === region && r.endContainer === region, + excludesHostPage: !sel.toString().includes('host item before'), + }; +}); + +// the last engaged region wins (local:active-root activeBlock precedence) +await load(); +await page.evaluate(() => { + const host = [...document.querySelectorAll('#embedded [contenteditable=true]')] + .find(el => el.textContent.includes('Embedded paragraph')); + host.focus(); + document.activeElement.blur(); + window.getSelection().removeAllRanges(); +}); +await page.keyboard.press(selectAllKey); +results.bodyCtrlAActiveRegion = await page.evaluate(() => { + const r = window.getSelection().getRangeAt(0); + const embedded = document.getElementById('embedded'); + return { selectsEngagedRegion: r.startContainer === embedded && r.endContainer === embedded }; +}); + +// ==== I. focused image island: Ctrl+A is stage 2, Backspace then runs the machine == +await load(); +const baselineI = await baselineOf(); +await page.evaluate(() => document.querySelector('#content figure img').focus()); +await page.keyboard.press(selectAllKey); +results.imageCtrlA = await page.evaluate(() => { + const r = window.getSelection().getRangeAt(0); + const region = document.getElementById('content'); + return { selectsRegion: r.startContainer === region && r.endContainer === region }; +}); +await page.keyboard.press('Backspace'); +results.imageCtrlA.deletesSelection = await page.evaluate(() => { + const region = document.getElementById('content'); + const blocks = [...region.children].filter(b => !b.hasAttribute('data-role')); + return blocks.length === 1 && blocks[0].tagName === 'P'; // seeded, not figure-only delete +}); +results.imageCtrlA.undoRestores = await settle('imageCtrlA', baselineI); + +console.log(JSON.stringify({ results, errors: errors.slice(0, 5) }, null, 2)); +await browser.close(); +const flat = JSON.stringify(results); +process.exit(errors.length || flat.includes('false') ? 1 : 0); From 19006e9f284c97b011c62b115f53ca62b6b1625a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 11 Jul 2026 08:23:44 +0300 Subject: [PATCH 2/2] Document select-all, selection delete and undo in the Help modal Co-Authored-By: Claude Fable 5 --- index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/index.html b/index.html index fe3c264..73a33b3 100644 --- a/index.html +++ b/index.html @@ -280,6 +280,14 @@

    Editing

    block, and Backspace at the start of a block to merge it with the one above.

    +

    Selecting and deleting

    +

    Press Ctrl/Cmd+A to select the current block's text; + press it again to select the whole document (never the surrounding + page). You can also drag a selection across several blocks with the + mouse. Backspace or Delete removes + whatever is selected, and Ctrl/Cmd+Z undoes any + change.

    +

    The toolbar

    Use the toolbar (top-left) to change a block's type, make text bold or italic, add links, lists, images and