diff --git a/CLAUDE.md b/CLAUDE.md
index 0390933..c064720 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
-RDFa-Editor is a prototype XHTML+RDFa authoring tool using client-side XSLT. Content lives in a `#content` container of structured blocks (p, h1–h3, ul/ol, blockquote, pre, figure, table), always editable Notion-style: typing within blocks, Enter splits, Backspace-at-start merges, toolbar for block types / inline formatting / lists / figures / tables / links, drag-handle reordering. Nesting follows the **XHTML Strict content model** (see content-model.xsl below): `blockquote` is a block *container* (`blockquote > p+` — bare text inside it is invalid and gets normalized; the toolbar quote toggle wraps/unwraps), `li`/`td`/`th`/`dd`/`figcaption` are `%Flow;` (mixed text AND nested blocks — Tab/Shift+Tab indent/outdent nested lists), `p`/`h1–h6`/`caption`/`dt` are inline-only. Tables are composite blocks (structure locked; `td`/`th`/`caption` editable) inserted via a rows×cols dialog with an optional header row and caption; row/column toolbar controls, Tab/Shift+Tab cell traversal and Enter (step down a column) all grow the grid at its bottom edge. Right-click a text selection to annotate it with RDFa; right-click an existing annotation to edit or remove it. "Extract RDF" shows the page's triples; "Source" shows the canonical XHTML+RDFa serialization (all editing ephemera stripped).
+RDFa-Editor is a prototype XHTML+RDFa authoring tool using client-side XSLT. Content lives in a `#content` container of structured blocks (p, h1–h3, ul/ol, blockquote, pre, figure, table), always editable Notion-style: typing within blocks, Enter splits, Backspace-at-start merges, toolbar for block types / inline formatting / lists / figures / tables / links, drag-handle reordering, a `/` slash menu (in an empty block) and markdown shortcuts (`# `, `- `, `1. `, `> `, ` ``` `) that insert/convert blocks per the content model. Nesting follows the **XHTML Strict content model** (see content-model.xsl below): `blockquote` is a block *container* (`blockquote > p+` — bare text inside it is invalid and gets normalized; the toolbar quote toggle wraps/unwraps), `li`/`td`/`th`/`dd`/`figcaption` are `%Flow;` (mixed text AND nested blocks — Tab/Shift+Tab indent/outdent nested lists), `p`/`h1–h6`/`caption`/`dt` are inline-only. Tables are composite blocks (structure locked; `td`/`th`/`caption` editable) inserted via a rows×cols dialog with an optional header row and caption; row/column toolbar controls, Tab/Shift+Tab cell traversal and Enter (step down a column) all grow the grid at its bottom edge. Right-click a text selection to annotate it with RDFa; right-click an existing annotation to edit or remove it. "Extract RDF" shows the page's triples; "Source" shows the canonical XHTML+RDFa serialization (all editing ephemera stripped).
## Architecture
@@ -29,7 +29,8 @@ 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 (`
` 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.
+- **select.xsl** — the Docs-style selection gesture layer, region-scoped select-all and cross-host selection delete (keyboard dispatch lives in edit.xsl's keydown/body/paste templates, mirroring the tables.xsl split; the mouse gesture templates live here). **The browser confines a native drag-selection to the host it starts in** (even background-origin drags clamp on the first host entered), so cross-block selection is **synthesized**: mousedown in a region arms a sweep anchor (`local:caret-at-point` — `caretRangeFromPoint`, `caretPositionFromPoint` fallback; chrome positions escape to just after the chrome); once the pointer leaves the anchor host, each `body|html` mousemove rebuilds the selection anchor→pointer via `setBaseAndExtent` — the only Selection API that can express a **backward selection** (upward drags keep the anchor fixed) — with the focus clamped into the anchor's region (`local:clamp-focus-to-region`, `comparePoint`-based) and a viewport nudge near its edges (native autoscroll dies with the takeover); in-host moves stay native. Mouseup disarms (innermost-match dispatch: the host/drag-handle mouseup templates disarm on their paths, `body|html` catches background ends) and `ondragstart` disarms defensively; a press on the drag handle never reaches the arm template. **Shift+Click** extends from the standing selection anchor to the clicked point (preventDefault keeps the anchor; re-arms so Shift+drag keeps extending; repeated Shift+Clicks share the anchor). **Shift+Up/Down** extend block-granularly past host edges (`local:shift-arrow-extends` gates: cross-host selection, or focus at the host edge facing the arrow — probed from the *focus*, not the range end; `local:extend-selection-block-wise` steps the focus between region-level child positions, whole blocks and composites as units, flipping direction across the anchor); within the host they stay native, Shift+Left/Right stay native throughout. Sweep state (`sweepAnchorNode/-Offset/-Host`, `sweepRegion`) lives on the editor-state container and is cleared on undo restore. **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 every synthetic gesture 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; `local:clamped-range` also moves boundaries out of chrome) — one gesture, one region-keyed undo entry; other regions stay byte-identical. A **printable character replaces** the selection (the delete machine places the caret, `local:insert-text-at-caret` lands the character — same undo entry); Enter/Tab/paste are suppressed; plain arrows stay native. **Canonical copy/cut** (`ixsl:oncopy`/`oncut` on hosts and body): a cross-host selection is copied in its storage form — `cloneContents` into a carrier div, `mode="canonical"` + `cm:normalize` (the paste pipeline in reverse), so the clipboard's HTML flavor has no chrome/`contenteditable`/marker classes but keeps RDFa attributes; cut adds the delete machine (one undo entry); within-host copy stays native.
+- **input.xsl** — Notion-style input affordances (ported from PR #10 onto the content-model era; dispatch additions in edit.xsl): one **priority-raised `ixsl:onbeforeinput` dispatcher** intercepts printable-char triggers and `next-match`es into undo's typing coalescer, so plain typing still snapshots. Triggers act on the **host** the caret sits in and are content-model-gated (`local:trigger-kind` — a shorthand whose result the model rejects stays literal). The **slash menu** (`/` in an empty host): filterable, keyboard-navigable, showing only the commands the context admits (conversions for `p|h1–h3|pre` hosts, Quote where the wrap is legal, lists where the model places one, Figure…/Table… always — routed to the existing dialogs via `insertHost`, whose saves place per the content model through `local:insert-block-at-caret`: an empty list item or cell grows the composite *inside* itself); state on `slashHost`, torn down by `local:hide-dialogs`. **Markdown shorthands** in a paragraph host: `#`/`##`/`###`+space, `-`/`*`+space, `1.`+space, ``` ``` ``` convert in place (`local:convert-block` with the pre-strip snapshot — one undo entry restores the literal marker); `> ` **wraps** via `local:wrap-in-blockquote` (`blockquote > p`; converting the `p` itself would be invalid). Caret popups position via `local:show-at-caret`/`-element` over overlay.xsl's `local:show-at-point` split.
- **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.
@@ -66,6 +67,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), `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`).
+- 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, type-to-replace, canonical copy/cut, composite/clamp semantics, plus the real gestures: drag takeover with real mouse events, Shift+Click, Shift+Up/Down, gutter/backward/cross-region drags, drag-handle non-hijack), `notion.mjs` (slash menu, markdown input rules, context filtering) 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/index.html b/index.html
index 73a33b3..b6dd22a 100644
--- a/index.html
+++ b/index.html
@@ -285,8 +285,16 @@ Selecting and deleting
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.
+ whatever is selected — or just start typing to replace it.
+ Ctrl/Cmd+C copies a multi-block selection as clean
+ XHTML+RDFa, and Ctrl/Cmd+Z undoes any change.
+
+ Quick insert
+ Type / in an empty block to open the insert menu.
+ Markdown shortcuts work too: # , ##
+ and ### for headings, - or
+ 1. for lists, > for a quote and
+ ``` for code.
The toolbar
Use the toolbar (top-left) to change a block's type, make text
diff --git a/rdfa-editor.css b/rdfa-editor.css
index 97f5bb3..1dac6b1 100644
--- a/rdfa-editor.css
+++ b/rdfa-editor.css
@@ -254,6 +254,49 @@
margin-top: 14px;
}
+/* Slash menu (/ to insert a block) */
+ #slash-menu {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+ padding: 6px;
+ min-width: 220px;
+ max-height: 320px;
+ overflow-y: auto;
+ z-index: 10000;
+ }
+
+.rdfa-editor-ui .slash-filter {
+ width: 100%;
+ padding: 7px 9px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 14px;
+ box-sizing: border-box;
+ margin-bottom: 6px;
+ }
+
+.rdfa-editor-ui .slash-items {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+.rdfa-editor-ui .slash-item {
+ padding: 7px 10px;
+ border-radius: 4px;
+ font-size: 14px;
+ color: #212121;
+ cursor: pointer;
+ }
+
+.rdfa-editor-ui .slash-item:hover,
+ .rdfa-editor-ui .slash-item[aria-selected="true"] {
+ background: #e3f2fd;
+ color: #0d47a1;
+ }
+
/* ToC jumps land below the fixed navbar */
.rdfa-editor-content > * {
scroll-margin-top: 76px;
diff --git a/src/edit.xsl b/src/edit.xsl
index d06611d..9b7e1ea 100644
--- a/src/edit.xsl
+++ b/src/edit.xsl
@@ -282,6 +282,7 @@ version="3.0">
+
@@ -579,18 +580,40 @@ version="3.0">
+
+
+
+
+
+
+
+ delete (the browser refuses to edit across host boundaries), a
+ printable character replaces the selection (delete, then the
+ character lands at the machine-placed caret), Enter/Tab are
+ suppressed so the selection cannot be half-edited; plain arrows
+ stay native (they collapse it) and the chord cases above keep
+ copy/cut native (canonical copy intercepts at the event level) -->
-
+
+
+
+
+
+
+
+
+
@@ -755,6 +778,26 @@ version="3.0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1716,9 +1759,12 @@ version="3.0">
+ quote or cell) whose top-level block is the container. Restore only a
+ node that MOVES with the children (a descendant); an element-level
+ caret on the block itself dangles after replaceWith, so it falls back
+ to the start of the new block (matters for empty-block conversions) -->
+ then ixsl:get($selection, 'anchorNode')[local:host-of(.) is $block][not(. is $block)] else ()"/>
@@ -1803,8 +1849,10 @@ version="3.0">
dt). Chrome stays a top-level affordance -->
+
+
-
+ id('table-dialog', ixsl:page()), id('find-dialog', ixsl:page()),
+ id('slash-menu', ixsl:page())">
-
+
+
@@ -2042,8 +2092,8 @@ version="3.0">
-
+
@@ -2089,18 +2139,13 @@ version="3.0">
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -2124,10 +2169,13 @@ version="3.0">
+
+
+
@@ -2151,6 +2199,7 @@ version="3.0">
carry no block identity and snap back; treat it as dragging its block -->
+
diff --git a/src/index.xsl b/src/index.xsl
index 1f2295c..d6314f5 100644
--- a/src/index.xsl
+++ b/src/index.xsl
@@ -24,6 +24,7 @@ version="3.0">
- 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
+ - input.xsl input triggers: slash menu and markdown shorthands
-->
@@ -38,6 +39,7 @@ version="3.0">
+
@@ -50,12 +52,14 @@ version="3.0">
LinkedDataHub's window.LinkedDataHub); reached everywhere via local:editor-state() -->
+ 'editRange', 'editingLink', 'insertHost', 'lastUndoHost',
+ 'breadcrumbLeaf', 'draggedSectionHeading', 'findNode', 'tocRoot',
+ 'slashHost', 'sweepAnchorNode', 'sweepAnchorHost', 'sweepRegion')">
+
diff --git a/src/input.xsl b/src/input.xsl
new file mode 100644
index 0000000..9d2bbcf
--- /dev/null
+++ b/src/input.xsl
@@ -0,0 +1,424 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/navigate.xsl b/src/navigate.xsl
index 2c3dff5..777790d 100644
--- a/src/navigate.xsl
+++ b/src/navigate.xsl
@@ -429,6 +429,9 @@ version="3.0">
+
+
diff --git a/src/overlay.xsl b/src/overlay.xsl
index ca89f16..932ef3a 100644
--- a/src/overlay.xsl
+++ b/src/overlay.xsl
@@ -243,12 +243,26 @@ version="3.0">
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/src/select.xsl b/src/select.xsl
index 45ea91a..930e7aa 100644
--- a/src/select.xsl
+++ b/src/select.xsl
@@ -11,18 +11,27 @@ xpath-default-namespace="http://www.w3.org/1999/xhtml"
version="3.0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/undo.xsl b/src/undo.xsl
index 02609ee..257d689 100644
--- a/src/undo.xsl
+++ b/src/undo.xsl
@@ -180,8 +180,8 @@ version="3.0">
+ 'insertHost', 'range', 'breadcrumbLeaf', 'findNode', 'lastUndoHost',
+ 'draggedSectionHeading', 'slashHost', 'sweepAnchorNode', 'sweepAnchorHost', 'sweepRegion')">
diff --git a/tests/browser/notion.mjs b/tests/browser/notion.mjs
new file mode 100644
index 0000000..9dd8f7f
--- /dev/null
+++ b/tests/browser/notion.mjs
@@ -0,0 +1,316 @@
+// Slash menu and markdown input rules (ported from PR #10 onto the content-model
+// era): the / menu opens in an empty host with only the commands the caret
+// context admits, converts/inserts via the existing toolbar machinery, and the
+// markdown shorthands act on the HOST (top-level p, quote-p, cell-p) with
+// content-model gating - '> ' WRAPS in a blockquote (blockquote > p), a
+// shorthand in a non-paragraph host stays literal. Every mutation must satisfy
+// the I1-I5 invariants (fixture-nesting.html).
+import { chromium } from 'playwright';
+
+const BASE = process.env.BASE_URL ?? 'http://localhost:8080';
+
+const errors = [];
+const results = {};
+const assert = (name, cond) => { results[name] = cond; if (!cond) errors.push('ASSERT FAILED: ' + name); };
+
+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());
+
+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'));
+};
+const visible = id => page.evaluate(i => {
+ const el = document.getElementById(i);
+ return !!el && getComputedStyle(el).display !== 'none';
+}, id);
+const shownCommands = () => page.evaluate(() =>
+ [...document.querySelectorAll('#slash-menu li.slash-item')]
+ .filter(li => getComputedStyle(li).display !== 'none').map(li => li.dataset.command));
+// caret at the end of the host carrying the needle, then a fresh empty paragraph after it
+const freshPara = async (needle = 'Intro paragraph') => {
+ await page.evaluate(txt => {
+ const host = [...document.querySelectorAll('#content [contenteditable=true]')]
+ .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(txt)));
+ host.focus();
+ const t = [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(txt));
+ window.getSelection().collapse(t, t.textContent.length);
+ }, needle);
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(50);
+};
+// empty the host carrying the needle via a within-host selection + native delete
+const emptyHost = async needle => {
+ await page.evaluate(txt => {
+ const host = [...document.querySelectorAll('#content [contenteditable=true]')]
+ .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(txt)));
+ host.focus();
+ const t = [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(txt));
+ const r = document.createRange();
+ r.setStart(t, 0); r.setEnd(t, t.textContent.length);
+ const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
+ }, needle);
+ await page.keyboard.press('Backspace');
+ await page.waitForTimeout(50);
+};
+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());
+ if (document.getElementById('lint-badge').style.display !== 'none')
+ v.push('lint: ' + document.getElementById('lint-badge').textContent);
+ return v;
+});
+const clean = async name => {
+ const v = await invariants();
+ assert(name + '.invariants', v.length === 0);
+ if (v.length) errors.push(`${name}: ${v.join('; ')}`);
+};
+
+// ---------------------------------------------------------------- slash menu
+
+await load();
+await freshPara();
+await page.keyboard.type('/');
+await page.waitForTimeout(150);
+assert('slash.opens', await visible('slash-menu'));
+assert('slash.filterFocused', await page.evaluate(() =>
+ document.activeElement.classList.contains('slash-filter')));
+assert('slash.allCommandsAtTopLevel', (await shownCommands()).length === 10);
+
+// filter narrows to a single command
+await page.fill('#slash-menu input.slash-filter', 'quote');
+await page.waitForTimeout(80);
+assert('slash.filters', JSON.stringify(await shownCommands()) === '["blockquote"]');
+
+// picking Heading 2 converts the empty block in place, caret returns
+await page.fill('#slash-menu input.slash-filter', '');
+await page.waitForTimeout(50);
+await page.click('#slash-menu li.slash-item[data-command="h2"]');
+await page.waitForTimeout(80);
+assert('slash.convertsToHeading', await page.evaluate(() => {
+ const h = [...document.querySelectorAll('#content > h2')].find(x => x.textContent.replace(/⠿/g, '') === '');
+ return !!h && h.getAttribute('contenteditable') === 'true'
+ && !!h.querySelector(':scope > [data-role=chrome]')
+ && h.contains(window.getSelection().anchorNode);
+}));
+assert('slash.closed', !(await visible('slash-menu')));
+await clean('slash.convert');
+// one undo unwinds the conversion back to a paragraph
+await page.keyboard.press('Control+z');
+await page.waitForTimeout(80);
+assert('slash.undo', await page.evaluate(() =>
+ ![...document.querySelectorAll('#content > h2')].some(x => x.textContent.replace(/⠿/g, '') === '')));
+
+// keyboard path: / then ArrowDown+Enter picks the second visible item (Heading 1)
+await load();
+await freshPara();
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+await page.keyboard.press('ArrowDown');
+await page.keyboard.press('Enter');
+await page.waitForTimeout(80);
+assert('slash.keyboardPick', await page.evaluate(() =>
+ [...document.querySelectorAll('#content > h1')].some(h => h.textContent.replace(/⠿/g, '') === '')));
+
+// list command replaces the empty paragraph with a single-item list
+await load();
+await freshPara();
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+await page.click('#slash-menu li.slash-item[data-command="ol"]');
+await page.waitForTimeout(80);
+assert('slash.list', await page.evaluate(() => {
+ const ol = document.querySelector('#content > ol');
+ const li = ol?.querySelector('li');
+ return !!li && li.getAttribute('contenteditable') === 'true'
+ && !!ol.querySelector(':scope > [data-role=chrome]')
+ && li.contains(window.getSelection().anchorNode);
+}));
+await clean('slash.list');
+
+// / with existing text is left literal (not a trigger)
+await load();
+await freshPara();
+await page.keyboard.type('a/');
+await page.waitForTimeout(100);
+assert('slash.literalWithText', !(await visible('slash-menu'))
+ && await page.evaluate(() => document.activeElement.textContent.includes('a/')));
+
+// context filtering: an emptied list item only offers figure/table
+await load();
+await emptyHost('Plain item');
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+assert('slash.liContext', JSON.stringify(await shownCommands()) === '["figure","table"]');
+await page.keyboard.press('Escape');
+await page.waitForTimeout(80);
+assert('slash.escapeCloses', !(await visible('slash-menu'))
+ && await page.evaluate(() => !!document.activeElement.closest('#content')));
+
+// an emptied cell paragraph (flow context) offers everything
+await load();
+await emptyHost('Cell para');
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+assert('slash.cellContext', (await shownCommands()).length === 10);
+await page.keyboard.press('Escape');
+
+// figure command routes to the existing dialog; placement follows the content
+// model - a cell is %Flow;, so the figure grows INSIDE the emptied cell
+await load();
+await emptyHost('Plain cell');
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+await page.click('#slash-menu li.slash-item[data-command="figure"]');
+await page.waitForTimeout(80);
+assert('slash.figureDialogOpens', await visible('figure-dialog'));
+await page.fill('#figure-dialog input[name="src"]', 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
+await page.click('#figure-dialog button.figure-save');
+await page.waitForTimeout(80);
+assert('slash.figureInsideCell', await page.evaluate(() => {
+ const td = document.querySelector('#content > table tr > td:nth-child(2)');
+ const fig = td?.querySelector(':scope > figure');
+ return !!fig && td.getAttribute('contenteditable') !== 'true' // cell became a container
+ && !fig.querySelector('[data-role=chrome]') // nested: no drag handle
+ && document.querySelectorAll('#content > figure').length === 1; // only the fixture's own
+}));
+await clean('slash.figure');
+
+// the user-reported shape: a table inserted from an empty list item nests INSIDE
+// the item (li is %Flow;), never after the whole list
+await load();
+const baselineTableInLi = await page.evaluate(() => document.getElementById('content').innerHTML);
+await emptyHost('Plain item');
+await page.keyboard.type('/');
+await page.waitForTimeout(120);
+await page.click('#slash-menu li.slash-item[data-command="table"]');
+await page.waitForTimeout(80);
+assert('slash.tableDialogOpens', await visible('table-dialog'));
+await page.click('#table-dialog button.table-save'); // 3x3 defaults
+await page.waitForTimeout(80);
+assert('slash.tableInsideLi', await page.evaluate(() => {
+ const li = document.querySelector('#content > ul > li');
+ const table = li?.querySelector(':scope > table');
+ return !!table && li.getAttribute('contenteditable') !== 'true'
+ && !table.querySelector('[data-role=chrome]')
+ && document.querySelectorAll('#content > table').length === 1 // the fixture's own only
+ && table.contains(window.getSelection().anchorNode); // caret in the first cell
+}));
+await clean('slash.tableInsideLi');
+await page.keyboard.press('Control+z');
+await page.waitForTimeout(80);
+assert('slash.tableInsideLiUndo', await page.evaluate(html =>
+ // one undo removes the table; the emptied item is a separate earlier entry
+ !document.querySelector('#content > ul > li > table'), baselineTableInLi));
+
+// ---------------------------------------------------------------- markdown rules
+
+await load();
+await freshPara();
+await page.keyboard.type('# ');
+await page.waitForTimeout(80);
+assert('md.heading', await page.evaluate(() => {
+ const h = [...document.querySelectorAll('#content > h1')].find(x => x.textContent.replace(/⠿/g, '') === '');
+ return !!h && h.contains(window.getSelection().anchorNode);
+}));
+await clean('md.heading');
+// one undo restores the paragraph with the literal marker
+await page.keyboard.press('Control+z');
+await page.waitForTimeout(80);
+assert('md.headingUndo', await page.evaluate(() =>
+ [...document.querySelectorAll('#content > p')].some(p => p.textContent.replace(/⠿/g, '') === '#')));
+
+await load();
+await freshPara();
+await page.keyboard.type('- ');
+await page.waitForTimeout(80);
+assert('md.bulletList', await page.evaluate(() => {
+ const ul = [...document.querySelectorAll('#content > ul')]
+ .find(u => u.querySelector('li') && u.textContent.replace(/⠿/g, '').trim() === '');
+ return !!ul && ul.querySelector('li').contains(window.getSelection().anchorNode);
+}));
+await clean('md.bulletList');
+
+await load();
+await freshPara();
+await page.keyboard.type('1. ');
+await page.waitForTimeout(80);
+assert('md.orderedList', await page.evaluate(() =>
+ [...document.querySelectorAll('#content > ol')].some(ol => ol.querySelector('li'))));
+
+// '> ' WRAPS per the content model: blockquote > p, chrome on the quote
+await load();
+await freshPara();
+await page.keyboard.type('> ');
+await page.waitForTimeout(80);
+assert('md.quoteWraps', await page.evaluate(() => {
+ const q = [...document.querySelectorAll('#content > blockquote')].find(b => !b.textContent.includes('Bare quote'));
+ const p = q?.querySelector(':scope > p');
+ return !!p && p.getAttribute('contenteditable') === 'true'
+ && p.textContent === '' && !!q.querySelector(':scope > [data-role=chrome]')
+ && !p.querySelector('[data-role=chrome]');
+}));
+await clean('md.quoteWraps');
+
+await load();
+await freshPara();
+await page.keyboard.type('```');
+await page.waitForTimeout(80);
+assert('md.pre', await page.evaluate(() =>
+ [...document.querySelectorAll('#content > pre')].some(pre => pre.textContent.replace(/⠿/g, '') === '')));
+
+// a shorthand inside a quote paragraph converts the nested host alone
+await load();
+await emptyHost('Bare quote text');
+await page.keyboard.type('## ');
+await page.waitForTimeout(80);
+assert('md.nestedQuoteHeading', await page.evaluate(() => {
+ const q = [...document.querySelectorAll('#content > blockquote')][0];
+ return !!q?.querySelector(':scope > h2[contenteditable=true]');
+}));
+await clean('md.nestedQuoteHeading');
+
+// a shorthand in a non-paragraph host stays literal (an li is not a p)
+await load();
+await emptyHost('Plain item');
+await page.keyboard.type('- ');
+await page.waitForTimeout(80);
+assert('md.literalInLi', await page.evaluate(() => {
+ const li = [...document.querySelectorAll('#content > ul > li')][0];
+ // contenteditable renders a trailing space as NBSP
+ return li.textContent.replace(/ /g, ' ') === '- ' && !li.querySelector('ul');
+}));
+
+// mid-text marker stays literal
+await load();
+await page.evaluate(() => {
+ const host = [...document.querySelectorAll('#content > p')].find(p => p.textContent.includes('Intro'));
+ host.focus();
+ const t = [...host.childNodes].find(n => n.nodeType === 3);
+ window.getSelection().collapse(t, t.textContent.length);
+});
+await page.keyboard.type('# ');
+await page.waitForTimeout(80);
+assert('md.literalMidText', await page.evaluate(() =>
+ [...document.querySelectorAll('#content > p')].some(p =>
+ p.textContent.replace(/ /g, ' ').includes('Intro paragraph.# '))));
+
+console.log(JSON.stringify({ results, errors: errors.slice(0, 8) }, null, 2));
+await browser.close();
+process.exit(errors.length ? 1 : 0);
diff --git a/tests/browser/run.mjs b/tests/browser/run.mjs
index 16bd8f0..d14fd5d 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', 'select']) {
+for (const suite of ['editor', 'features', 'fixes', 'hardening', 'multiinstance', 'tables', 'datatype', 'inspector', 'nesting', 'authoring', 'invariants', 'select', 'notion']) {
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
index 2cbfec0..9cd50f7 100644
--- a/tests/browser/select.mjs
+++ b/tests/browser/select.mjs
@@ -294,13 +294,41 @@ results.crossRegionClamp = await page.evaluate(html => {
}, embeddedBefore);
results.crossRegionClamp.undoRestores = await settle('crossRegionClamp', baselineF);
-// ==== G. suppression: only Backspace/Delete act on a cross-host selection ==========
+// ==== G. type-to-replace: a printable character replaces the selection ============
await load();
+const baselineG = await baselineOf();
await caretIn('Intro paragraph', 3);
await page.keyboard.press(selectAllKey);
await page.keyboard.press(selectAllKey);
-const beforeSuppress = await baselineOf();
await page.keyboard.type('x');
+results.typeReplace = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const blocks = [...region.children].filter(b => !b.hasAttribute('data-role'));
+ const sel = window.getSelection();
+ return {
+ replaced: blocks.length === 1 && blocks[0].tagName === 'P'
+ && blocks[0].textContent.replace(/⠿/g, '') === 'x',
+ caretAfterChar: sel.isCollapsed && sel.anchorNode.textContent === 'x' && sel.anchorOffset === 1,
+ };
+});
+results.typeReplace.undoRestores = await settle('typeReplace', baselineG);
+
+// sweep + type: the character lands at the merge seam
+await load();
+await sweep('Intro paragraph', 6, 'Plain item', 6);
+await page.keyboard.type('Z');
+results.typeReplaceSweep = await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro'));
+ return { seamChar: !!p && p.textContent.replace(/⠿/g, '') === 'Intro Zitem' };
+});
+results.typeReplaceSweep.undoRestores = await settle('typeReplaceSweep', baselineG);
+
+// Enter/Tab/paste stay suppressed; the selection survives them
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press(selectAllKey);
+const beforeSuppress = await baselineOf();
await page.keyboard.press('Enter');
await page.keyboard.press('Tab');
await page.evaluate(() => {
@@ -318,6 +346,62 @@ results.suppression = {
}),
};
+// ==== J. canonical copy / cut ======================================================
+// copy of a stage-2 selection: the clipboard gets the storage form, not the
+// editing DOM - no chrome/contenteditable/marker classes, RDFa attributes kept
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press(selectAllKey);
+const copied = await page.evaluate(() => {
+ const dt = new DataTransfer();
+ document.activeElement.dispatchEvent(
+ new ClipboardEvent('copy', { clipboardData: dt, bubbles: true, cancelable: true }));
+ return { html: dt.getData('text/html'), plain: dt.getData('text/plain') };
+});
+results.canonicalCopy = {
+ intercepted: copied.html.length > 0,
+ noChrome: !copied.html.includes('data-role') && !copied.html.includes('⠿'),
+ noEditingAttrs: !copied.html.includes('contenteditable') && !copied.html.includes('rdfa-editor'),
+ rdfaKept: copied.html.includes('property="http://purl.org/dc/terms/title"'),
+ blocksKept: copied.html.includes('
+ document.getElementById('content').textContent.includes('Intro paragraph.')),
+};
+
+// within-host copy stays native (no interception, clipboardData untouched by us)
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey); // stage 1: within-host
+results.nativeCopyWithinHost = {
+ notIntercepted: await page.evaluate(() => {
+ const dt = new DataTransfer();
+ const e = new ClipboardEvent('copy', { clipboardData: dt, bubbles: true, cancelable: true });
+ document.activeElement.dispatchEvent(e);
+ return !e.defaultPrevented && dt.getData('text/html') === '';
+ }),
+};
+
+// cut: clipboard set AND the selection deleted with merge, one undo restores
+await load();
+const baselineJ = await baselineOf();
+await sweep('Intro paragraph', 6, 'Plain item', 6);
+const cutData = await page.evaluate(() => {
+ const dt = new DataTransfer();
+ document.activeElement.dispatchEvent(
+ new ClipboardEvent('cut', { clipboardData: dt, bubbles: true, cancelable: true }));
+ return { html: dt.getData('text/html') };
+});
+results.canonicalCut = {
+ clipboardSet: cutData.html.includes('paragraph.') && cutData.html.includes('Plain'),
+ deletedAndMerged: await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro'));
+ return !!p && p.textContent.replace(/⠿/g, '') === 'Intro item';
+ }),
+};
+results.canonicalCut.undoRestores = await settle('canonicalCut', baselineJ);
+
// ==== 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();
@@ -370,6 +454,231 @@ results.imageCtrlA.deletesSelection = await page.evaluate(() => {
});
results.imageCtrlA.undoRestores = await settle('imageCtrlA', baselineI);
+// ==== K. real gestures: drag takeover, Shift+Click, Shift+Up/Down ==================
+// everything above builds ranges programmatically (the machinery); these cases
+// assert the GESTURES produce them. Chromium clamps a native drag-selection to
+// the host it starts in, so the select.xsl mousemove takeover is what makes
+// these pass - they fail on a build without the gesture layer.
+
+// viewport point of a character offset within the host containing needle (-1 = end)
+const pointAt = (needle, off) => page.evaluate(([n, o]) => {
+ const host = [...document.querySelectorAll('[contenteditable=true]')]
+ .find(el => [...el.childNodes].some(t => t.nodeType === 3 && t.textContent.includes(n)));
+ const t = [...host.childNodes].find(t => t.nodeType === 3 && t.textContent.includes(n));
+ const r = document.createRange();
+ r.setStart(t, o === -1 ? t.textContent.length : o);
+ r.collapse(true);
+ const rect = r.getBoundingClientRect();
+ return { x: rect.x, y: rect.y + rect.height / 2 };
+}, [needle, off]);
+
+const dragReal = async (from, to) => {
+ await page.mouse.move(from.x, from.y);
+ await page.mouse.down();
+ for (let i = 1; i <= 8; i++)
+ await page.mouse.move(from.x + (to.x - from.x) * i / 8, from.y + (to.y - from.y) * i / 8);
+ await page.mouse.up();
+};
+
+// anchor/focus hosts and range text; sel.toString() truncates at the first host
+// boundary in Chromium, so extent is asserted via the RANGE
+const selInfo = () => page.evaluate(() => {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return { crosses: false, rangeText: '' };
+ const hostOf = n => { const el = n.nodeType === 1 ? n : n.parentElement; return el.closest('[contenteditable=true]'); };
+ const a = hostOf(sel.anchorNode), f = hostOf(sel.focusNode);
+ return {
+ collapsed: sel.isCollapsed,
+ crosses: !sel.isCollapsed && (a !== f || !a || !f),
+ anchorText: a ? a.textContent.replace(/⠿/g, '').trim() : '(region)',
+ focusText: f ? f.textContent.replace(/⠿/g, '').trim() : '(region)',
+ rangeText: sel.getRangeAt(0).toString().replace(/⠿/g, ''),
+ disarmed: window.rdfaEditor.sweepAnchorNode == null,
+ };
+});
+
+// K1: a real drag across three blocks paints one selection
+await load();
+{
+ await dragReal(await pointAt('Intro paragraph', 6), await pointAt('Sub two', 4));
+ const s = await selInfo();
+ results.realDrag = {
+ crosses: s.crosses,
+ spansBlocks: s.rangeText.includes('paragraph.') && s.rangeText.includes('Plain item')
+ && s.rangeText.includes('Sub one'),
+ disarmedOnMouseup: s.disarmed,
+ };
+}
+
+// K2: real drag + Backspace = the same edge-remnant merge as the programmatic sweep
+// (pointer-to-offset resolution is approximate: assertions tolerate a char or two)
+await load();
+const baselineK = await baselineOf();
+{
+ await dragReal(await pointAt('Intro paragraph', 6), await pointAt('Plain item', 6));
+ await page.keyboard.press('Backspace');
+ results.realDragDelete = 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();
+ const text = p ? p.textContent.replace(/⠿/g, '') : '';
+ return {
+ merged: !!p && text.startsWith('Intro') && text.endsWith('item')
+ && !text.includes('paragraph'),
+ firstItemGone: items.length === 2 && items[0].textContent.includes('Mixed item'),
+ caretCollapsed: sel.rangeCount === 1 && sel.isCollapsed,
+ };
+ });
+ results.realDragDelete.undoRestores = await settle('realDragDelete', baselineK);
+}
+
+// K3: a backward (upward) drag keeps the anchor fixed - Docs semantics
+await load();
+{
+ await dragReal(await pointAt('Plain item', 6), await pointAt('Intro paragraph', 6));
+ const s = await selInfo();
+ results.backwardDrag = {
+ crosses: s.crosses,
+ anchorStaysAtStart: s.anchorText.includes('Plain item'),
+ focusAtDragEnd: s.focusText.includes('Intro'),
+ };
+ await page.keyboard.press('Delete');
+ results.backwardDrag.deletes = await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro'));
+ return !!p && p.textContent.endsWith('item') && !p.textContent.includes('paragraph');
+ });
+ results.backwardDrag.undoRestores = await settle('backwardDrag', baselineK);
+}
+
+// K4: an in-host drag stays native and confined
+await load();
+{
+ await dragReal(await pointAt('Intro paragraph', 0), await pointAt('Intro paragraph', 10));
+ const s = await selInfo();
+ results.inHostDrag = {
+ selected: !s.collapsed && s.rangeText.length > 0,
+ confined: !s.crosses && s.anchorText.includes('Intro') && s.focusText.includes('Intro'),
+ disarmedOnMouseup: s.disarmed,
+ };
+}
+
+// K5: a drag from the region's gutter (padding, left of the text) sweeps blocks
+await load();
+{
+ const from = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const p = [...region.children].find(b => b.textContent.includes('Intro paragraph'));
+ const rp = p.getBoundingClientRect();
+ return { x: region.getBoundingClientRect().x + 8, y: rp.y + rp.height / 2 };
+ });
+ await dragReal(from, await pointAt('Plain item', 6));
+ results.gutterDrag = { crosses: (await selInfo()).crosses };
+}
+
+// K6: Shift+Click extends from the caret; a second Shift+Click keeps the anchor
+await load();
+{
+ await caretIn('Intro paragraph', 6);
+ await page.keyboard.down('Shift');
+ await page.mouse.click(...Object.values(await pointAt('Plain item', 6)));
+ await page.keyboard.up('Shift');
+ const first = await selInfo();
+ await page.keyboard.down('Shift');
+ await page.mouse.click(...Object.values(await pointAt('Bare quote', 5)));
+ await page.keyboard.up('Shift');
+ const second = await selInfo();
+ results.shiftClick = {
+ extends: first.crosses && first.anchorText.includes('Intro'),
+ anchorSurvivesSecond: second.crosses && second.anchorText.includes('Intro')
+ && second.focusText.includes('Bare quote'),
+ };
+}
+
+// K7: Shift+Click then typing replaces the selection (one undo entry)
+await load();
+{
+ await caretIn('Intro paragraph', 6);
+ await page.keyboard.down('Shift');
+ await page.mouse.click(...Object.values(await pointAt('Plain item', 6)));
+ await page.keyboard.up('Shift');
+ await page.keyboard.press('X');
+ results.shiftClickType = await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro'));
+ const sel = window.getSelection();
+ const text = p ? p.textContent.replace(/⠿/g, '') : '';
+ return {
+ replaced: !!p && text.startsWith('Intro') && text.includes('X')
+ && !text.includes('paragraph'),
+ caretCollapsed: sel.rangeCount === 1 && sel.isCollapsed,
+ };
+ });
+ results.shiftClickType.undoRestores = await settle('shiftClickType', baselineK);
+}
+
+// K8: Shift+Down at the host end steps block-wise; Shift+Up steps back
+await load();
+{
+ await caretIn('Intro paragraph', -1);
+ await page.keyboard.press('Shift+ArrowDown');
+ const one = await selInfo();
+ await page.keyboard.press('Shift+ArrowDown');
+ const two = await selInfo();
+ await page.keyboard.press('Shift+ArrowUp');
+ const back = await selInfo();
+ results.shiftArrows = {
+ firstCrosses: one.crosses,
+ secondTakesList: two.rangeText.includes('Plain item') && two.rangeText.includes('Sub two'),
+ upReleasesList: !back.rangeText.includes('Plain item'),
+ baselineIntact: await page.evaluate(html =>
+ document.getElementById('content').innerHTML === html, baselineK),
+ };
+}
+
+// K9: a drag toward another region clamps to the region it started in
+await load();
+{
+ const to = await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#embedded [contenteditable=true]')]
+ .find(el => el.textContent.includes('Embedded paragraph'));
+ const r = p.getBoundingClientRect();
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
+ });
+ await dragReal(await pointAt('Intro paragraph', 6), to);
+ // Chromium may remap the region-level focus to the nearest editing position
+ // INSIDE the region - equally clamped; assert containment, not identity
+ results.dragRegionClamp = await page.evaluate(() => {
+ const sel = window.getSelection();
+ const embedded = document.getElementById('embedded');
+ const region = document.getElementById('content');
+ return {
+ crosses: !sel.isCollapsed,
+ clampedToStartRegion: sel.focusNode === region || region.contains(sel.focusNode),
+ embeddedUntouched: !sel.getRangeAt(0).intersectsNode(embedded),
+ };
+ });
+}
+
+// K10: a press on the drag handle never arms a sweep (block reordering intact)
+await load();
+{
+ const handle = await page.evaluate(() => {
+ const h = document.querySelector('#content > h1 > [data-role=chrome]');
+ const r = h.getBoundingClientRect();
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
+ });
+ await page.mouse.move(handle.x, handle.y);
+ await page.mouse.down();
+ const to = await pointAt('Plain item', 6);
+ for (let i = 1; i <= 4; i++)
+ await page.mouse.move(handle.x + (to.x - handle.x) * i / 4, handle.y + (to.y - handle.y) * i / 4);
+ results.handleNotHijacked = {
+ neverArmed: await page.evaluate(() => window.rdfaEditor.sweepAnchorNode == null),
+ noSweepSelection: !(await selInfo()).crosses,
+ };
+ await page.mouse.up();
+}
+
console.log(JSON.stringify({ results, errors: errors.slice(0, 5) }, null, 2));
await browser.close();
const flat = JSON.stringify(results);