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 (`
  • 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. +- **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);