From 075ba8795e7cea903a2f20f4a18ade14491b1e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 14 Jul 2026 22:44:26 +0300 Subject: [PATCH 1/5] Extend drag-and-drop to nested blocks via the content model Every %block element now carries its own drag handle wherever the model places blocks (a region, a cm:flow container or a blockquote) - lifting a block out of a list item, quote or cell, or dropping one in, resolves to the innermost legal level (local:deepest-block-at + local:legal-drop-level; illegal drops clamp to the nearest legal ancestor, the gutter lifts out to top level). The vacated origin is repaired at commit (prune-husks + collapse-container), local:ensure-chrome converges handles after every mutation and undo restore, and transient drag state is stripped before the undo snapshot (local:tidy-class kills class="" residue). Handles stack per nesting level; the editing view indents blockquote content so quote-child handles get their own gutter column. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +- rdfa-editor.css | 33 ++- src/edit.xsl | 158 +++++++++-- src/undo.xsl | 26 +- tests/browser/authoring.mjs | 4 +- tests/browser/dragnest.mjs | 257 ++++++++++++++++++ tests/browser/invariants.mjs | 19 +- tests/browser/nesting.mjs | 14 +- tests/browser/notion.mjs | 22 +- tests/browser/run.mjs | 2 +- tests/browser/select.mjs | 13 +- .../canonical/23-nested-chrome-strip.xhtml | 20 ++ .../lint/13-nested-object-div-clean.txt | 0 tests/fixture-dragnest.html | 54 ++++ .../canonical/23-nested-chrome-strip.xhtml | 29 ++ .../lint/13-nested-object-div-clean.xhtml | 10 + 16 files changed, 609 insertions(+), 60 deletions(-) create mode 100644 tests/browser/dragnest.mjs create mode 100644 tests/expected/canonical/23-nested-chrome-strip.xhtml create mode 100644 tests/expected/lint/13-nested-object-div-clean.txt create mode 100644 tests/fixture-dragnest.html create mode 100644 tests/fixtures/canonical/23-nested-chrome-strip.xhtml create mode 100644 tests/fixtures/lint/13-nested-object-div-clean.xhtml diff --git a/CLAUDE.md b/CLAUDE.md index e0b6da5..d43e27e 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, 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). +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 (nested blocks included — every `%block` drags, drops land wherever the content model admits it), 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 @@ -28,8 +28,8 @@ Modules under `src/`: - **overlay.xsl** — the annotation form (single copy), form reading (`local:form-values` map), pre-filling (`local:populate-form`), show/hide with viewport clamping (`local:show-at`, shared with the edit dialogs). The property/type fields are typeaheads (typeahead.xsl); the datatype field stays a `` (short XSD list) with a `urn:rdfa-editor:custom` sentinel + free-text `custom-datatype` input and the datatype↔lang mutual-exclusion handler. - **typeahead.xsl** — autocomplete lookups for the **property** and **type** fields (replacing the long dropdowns; LDH's typeahead interaction ported with local synchronous filtering — no SPARQL/debounce, since vocabularies are already pooled). A stable wrapper `span[@data-field]` holds one of two states, swapped via `result-document` literal result elements: typing (`input.typeahead-input` + `ul.typeahead-menu`) or committed (`button.typeahead-value` + hidden input carrying the IRI, LDH-style). Filtering re-renders per keystroke from `local:vocab-terms` (prefix-ranked, capped, bold-highlighted match); keyboard nav mirrors the slash menu (Arrow/Enter/Escape, first option pre-selected); options commit on `ixsl:onmousedown` (before the input's `focusout`, children `pointer-events:none` so the `
  • ` is the target). `local:typeahead-value` reads the committed hidden IRI, else a free-typed absolute IRI (`local:is-absolute-iri`), else the untouched button→edit value parked on `@data-editing-iri` (cleared on the first keystroke — the stale-selection guard). Escape closes the menu else the overlay (the innermost template fully owns the input's keys). - **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 covers **every draggable block** (`local:draggable-block`: a real `%block` — never a run wrapper, `hr` or ephemera — whose parent is a region, a `cm:flow` container or a `blockquote`): nested blocks carry their own handles, stacked per nesting level, each hover-revealed at its block's own gutter indent (the editing view indents `blockquote` content so quote-child handles don't coincide with the quote's); `local:ensure-chrome` in `local:after-mutation` converges handles after every gesture, so mutation sites never thread their own injection. 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) and extended to **nested blocks with a content-model-validated drop resolver**: the target is the innermost draggable block under the pointer whose container accepts the dragged kind (`local:deepest-block-at` + `local:legal-drop-level` climbing to the nearest legal level — the region accepts any block, so the climb terminates and an illegal drop is *clamped*, never created); gutter/gap drops fall back to the nearest top-level block, so dropping in the left gutter lifts a nested block all the way out; the vacated origin is repaired at commit (`local:prune-husks` for emptied structural containers — the B7 doctrine — then `local:collapse-container` for a flow container that lost its last block); transient drag state (`dragging`/`draggable`/empty `class` leftovers — `local:tidy-class`) is stripped before the undo snapshot; drops stay region-scoped (blocks never move between regions). 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**. +- **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 covers **every draggable block** (`local:draggable-block`: a real `%block` — never a run wrapper, `hr` or ephemera — whose parent is a region, a `cm:flow` container or a `blockquote`): nested blocks carry their own handles, stacked per nesting level, each hover-revealed at its block's own gutter indent (the editing view indents `blockquote` content so quote-child handles don't coincide with the quote's); `local:ensure-chrome` in `local:after-mutation` converges handles after every gesture, so mutation sites never thread their own injection. 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) and extended to **nested blocks with a content-model-validated drop resolver**: the target is the innermost draggable block under the pointer whose container accepts the dragged kind (`local:deepest-block-at` + `local:legal-drop-level` climbing to the nearest legal level — the region accepts any block, so the climb terminates and an illegal drop is *clamped*, never created); gutter/gap drops fall back to the nearest top-level block, so dropping in the left gutter lifts a nested block all the way out; **cells take "into" drops** (nothing can legally drop before/after a `td`/`th`, so a cell's pixels away from any block it holds mean *into it* — the text host becomes a container per the `local:insert-block-at-caret` flow doctrine, marked by an inset `drop-into` box; a list item deliberately stays a clamp to around-the-list, since its pixels compete with reordering); the vacated origin is repaired at commit (`local:prune-husks` for emptied structural containers — the B7 doctrine — then `local:collapse-container` for a flow container that lost its last block); transient drag state (`dragging`/`draggable`/empty `class` leftovers — `local:tidy-class`) is stripped before the undo snapshot; drops stay region-scoped (blocks never move between regions). 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 a (possibly nested) `` is foster-parented out by the HTML fragment parser on `innerHTML` restore, so `local:restore-snapshot` strips every handle and re-converges via `local:ensure-chrome` (deterministic first-child prepend) before caret resolution. - **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. diff --git a/rdfa-editor.css b/rdfa-editor.css index c97cc66..9325337 100644 --- a/rdfa-editor.css +++ b/rdfa-editor.css @@ -196,6 +196,11 @@ box-shadow: 0 3px 0 0 #2196f3; } +/* dropping INTO a cell highlights the cell itself, not an edge */ + .rdfa-editor-content .drop-into { + box-shadow: inset 0 0 0 2px #2196f3; + } + /* Editor toolbar (injected into the navbar) */ #edit-toolbar { order: -1; diff --git a/src/edit.xsl b/src/edit.xsl index 1b268c0..883c937 100644 --- a/src/edit.xsl +++ b/src/edit.xsl @@ -2297,8 +2297,12 @@ version="3.0"> + + [ if (not(local:draggable-block($target))) then 'drop-into' + else if (local:drop-before($event, $target)) then 'drop-before' + else 'drop-after' ])[current-date() lt xs:date('2000-01-01')]"/> @@ -2329,9 +2333,24 @@ version="3.0"> select="$dragged/ancestor::*[cm:flow(local-name(.))] [not(contains-token(@class, 'rdfa-editor-content'))] [exists(local:block-of(.))][1]"/> - + + + + + + + + + + + + + + + @@ -2388,7 +2407,21 @@ version="3.0"> select="($dragged ! local:root-of(.), local:root-of($hit))[1]"/> + + + + + @@ -2420,9 +2453,10 @@ version="3.0"> + or contains-token(@class, 'drop-after') or contains-token(@class, 'drop-into')]"> + diff --git a/tests/browser/dragnest.mjs b/tests/browser/dragnest.mjs index 47f7ffb..944d9e5 100644 --- a/tests/browser/dragnest.mjs +++ b/tests/browser/dragnest.mjs @@ -1,9 +1,10 @@ // Nested drag-and-drop: every %block carries its own handle wherever the content // model places blocks, and drops resolve to the innermost legal level - lifting a // nested block (incl. an RDFa object div) out of a list item, dropping a top-level -// block into a container, clamping over illegal spots, staying region-scoped. -// Real mouse gestures throughout (Playwright synthesizes HTML5 drag events from -// mouse.down + moves). Runs against fixture-dragnest.html. +// block into a container or INTO a table cell (a cell's pixels are unambiguous: +// nothing drops before/after a td), clamping over illegal spots, staying +// region-scoped. Real mouse gestures throughout (Playwright synthesizes HTML5 +// drag events from mouse.down + moves). Runs against fixture-dragnest.html. import { chromium } from 'playwright'; const BASE = process.env.BASE_URL ?? 'http://localhost:8080'; @@ -238,7 +239,52 @@ await load(); Object.assign(results.splitConverges, await settle(baseline)); } -// ==== S8: canonical serialization stays clean of nested chrome =================== +// ==== S8: a paragraph drops INTO a text-only cell ================================ +// (nothing can legally drop before/after a td, so a cell's pixels mean "into": +// the text host becomes a container - its text wrapped as a run - and the block +// appends; hovering a block INSIDE a cell still places before/after that block) +await load(); +{ + const baseline = await baselineOf(); + const cellPoint = await page.evaluate(() => { + const td = [...document.querySelectorAll('#content td')] + .find(t => t.textContent.includes('Plain cell')); + const r = td.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await dragReal(await handleAt('#content > h1 + p'), cellPoint); + results.dropIntoCell = await page.evaluate(() => { + const td = [...document.querySelectorAll('#content td')] + .find(t => t.textContent.includes('Plain cell')); + return { + nested: !!td.querySelector(':scope > p:not(.rdfa-editor-run)') + && td.querySelector(':scope > p:not(.rdfa-editor-run)').textContent.includes('Intro paragraph'), + cellIsContainer: td.getAttribute('contenteditable') !== 'true', + textKeptAsRun: td.querySelector(':scope > p.rdfa-editor-run')?.textContent === 'Plain cell', + markCleared: !td.classList.contains('drop-into'), + }; + }); + Object.assign(results.dropIntoCell, await settle(baseline)); +} + +// ==== S9: a block inside a cell still takes precise before/after drops =========== +await load(); +{ + const baseline = await baselineOf(); + await dragReal(await handleAt('#content > h1 + p'), await pointIn('#content td > p', 0.2)); + results.dropBeforeCellBlock = await page.evaluate(() => { + const td = [...document.querySelectorAll('#content td')] + .find(t => t.textContent.includes('Cell para')); + const kids = [...td.children].filter(c => !c.hasAttribute('data-role')); + return { + placedBefore: kids.length === 2 && kids[0].textContent.includes('Intro paragraph') + && kids[1].textContent.includes('Cell para'), + }; + }); + Object.assign(results.dropBeforeCellBlock, await settle(baseline)); +} + +// ==== S10: canonical serialization stays clean of nested chrome ================== await load(); { await page.click('#view-source'); diff --git a/tests/fixture-dragnest.html b/tests/fixture-dragnest.html index cf50e10..d235aa7 100644 --- a/tests/fixture-dragnest.html +++ b/tests/fixture-dragnest.html @@ -44,6 +44,14 @@

    Drag nesting demo

  • Block item

    Quote in item

  • Tail paragraph.

    +
    + + + + + + +

    Cell para

    Plain cell
    From 30e6b0f3ca5bc925c4b66a4230344634e21dad68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 15 Jul 2026 00:34:31 +0300 Subject: [PATCH 3/5] Reflect nested drag-and-drop in the demo and help docs The help modal's Reordering section, the demo's Try-it list (the quote's inner paragraph now demonstrates a nested handle; cells take drops) and the README feature list all described the old top-level-only handles. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++++--- index.html | 11 +++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba5f14e..c52c93d 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ RDFa extraction and the document canonicalization are all XSLT. - **Structured-block editing** of any number of `.rdfa-editor-content` regions (p, h1–h3, lists, blockquote, pre, figure): Enter splits, Backspace merges, toolbar for block types / inline - formatting / links / figures, drag-handle and Alt+Arrow reordering, HTML paste through - a sanitizing canonicalization pipeline, unified snapshot undo/redo with caret - restoration. + formatting / links / figures, drag-handle and Alt+Arrow reordering — nested blocks + carry their own handles and drop wherever the XHTML content model admits them (out of + a list item or quote, into a table cell) — HTML paste through a sanitizing + canonicalization pipeline, unified snapshot undo/redo with caret restoration. - **RDFa annotation**: right-click a selection to assert a statement (S/P/O framing, vocabulary dropdowns fed by plain ontology RDF/XML files); right-click an annotation to edit or remove it. diff --git a/index.html b/index.html index b6dd22a..1de806e 100644 --- a/index.html +++ b/index.html @@ -244,7 +244,10 @@

    Try it yourself

  • Type a sentence, then press Enter to start a new block.
  • Select a few words, then right-click to tag them with meaning.
  • Use the toolbar to add a heading, list, image or table.
  • -
  • Hover a block and drag the handle to reorder it.
  • +
  • Hover a block and drag the handle to reorder it — + even into a table cell.
  • +
  • Hover the quote above: the paragraph inside it has its own + handle, so you can drag it out of the quote.
  • Learn more

    @@ -314,7 +317,11 @@

    Adding meaning (RDFa)

    Reordering

    Hover over a block and drag the handle that appears in - the left margin to move it.

    + the left margin to move it. Blocks nested inside a list item, a quote or + a table cell have handles of their own, at their own indent — a drop + lands wherever the document structure allows it: next to another block, + into a table cell, or — dropped in the page's left + gutter — out at the top level.

    Seeing the result

    Source (in the toolbar) shows the clean, canonical From 28e6a86d9430cb98588c65b1b13b97d49c9a1928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 15 Jul 2026 00:35:39 +0300 Subject: [PATCH 4/5] Fix the last stale top-level-only chrome claim in CLAUDE.md The editable-region convention bullet still said init injects chrome top-level only; also name the into-cell drop mode in the dragnest suite description. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 426b103..6546a31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,11 +63,11 @@ Run `make sef` after any XSLT change; run the tests after any extractor change. - Vocabularies are plain ontology RDF/XML files in `vocabs/` — no custom manifest format. Adding a vocabulary = drop the file in `vocabs/` and add its href to `$vocab-hrefs`. - `src/index.xsl` (and other editor modules) may use DOCTYPE entities; the SaxonJS compiler does not expand them, hence the `xmlstarlet c14n` step in `generate-sef.sh`. The extractor is entity-free so the test suite runs against `src/` directly. - Tests: expected files are authored as readable, grouped RDF/XML; `tests/normalize.xsl` canonicalizes both sides into sorted triple lists, so prefixes/grouping/order don't matter. Blank-node labels are deterministic sibling-position paths (e.g. `b1.1.2`), not `generate-id()`. Canonical-XHTML fixtures live in `tests/fixtures/canonical/` and compare via `tests/normalize-xhtml.xsl` (whitespace-only text dropped only in element-only containers — `pre` stays exact). -- **Editable region convention**: the host page provides `

    ` with blocks as direct children and **no hardcoded `contenteditable`** — init sets editability recursively per the content model and injects chrome (top-level only). Everything carrying `@data-role` (chrome, rendering) is ephemeral: skipped by the extractor, stripped by canonicalization (LDH v6 contract). +- **Editable region convention**: the host page provides `
    ` with blocks as direct children and **no hardcoded `contenteditable`** — init sets editability recursively per the content model and injects chrome (on every draggable block, nested ones included — `local:draggable-block`). Everything carrying `@data-role` (chrome, rendering) is ephemeral: skipped by the extractor, stripped by canonicalization (LDH v6 contract). - **Run-wrapper contract**: `p.rdfa-editor-run` is an editing-DOM-only marker for the stray inline runs of mixed flow containers — created by init/paste, unwrapped by canonicalization (C11), promoted to a real `p` by structural gestures, never by plain typing. It renders margin-free via `rdfa-editor.css`. - **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, 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), `typeahead.mjs` (property/type autocomplete: filter, keyboard/mouse select, free-IRI entry, non-IRI rejection, edit-prefill button, stale-selection invalidation — via `typeahead-helper.mjs`), `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 only on draggable blocks, run wrappers editable, zero lint issues, undo restores the exact baseline) and `dragnest.mjs` (nested drag-and-drop with real mouse gestures: nested handles at init, lift-out of a list item — incl. the RDFa object-div case — drop-into a container, legality clamp, region clamp, origin collapse, split convergence, canonical cleanliness — against `tests/fixture-dragnest.html`), all others 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), `typeahead.mjs` (property/type autocomplete: filter, keyboard/mouse select, free-IRI entry, non-IRI rejection, edit-prefill button, stale-selection invalidation — via `typeahead-helper.mjs`), `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 only on draggable blocks, run wrappers editable, zero lint issues, undo restores the exact baseline) and `dragnest.mjs` (nested drag-and-drop with real mouse gestures: nested handles at init, lift-out of a list item — incl. the RDFa object-div case — drop-into a container and INTO a text-only cell, before/after precision on a cell's blocks, legality clamp, region clamp, origin collapse, split convergence, canonical cleanliness — against `tests/fixture-dragnest.html`), all others 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. From 0c443f8404760888b937ecdb23c941d9e95da158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 15 Jul 2026 00:49:19 +0300 Subject: [PATCH 5/5] Give blockquotes a quote bar in the editing view A normalized blockquote > p reads as a plain paragraph without a visual clue; the Markdown/Bootstrap-style left border (with muted text) makes the container visible, and the slightly wider padding keeps the inner blocks' drag handles clear of the bar. Co-Authored-By: Claude Fable 5 --- rdfa-editor.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rdfa-editor.css b/rdfa-editor.css index 9325337..cd4a9b1 100644 --- a/rdfa-editor.css +++ b/rdfa-editor.css @@ -103,9 +103,13 @@ /* a blockquote has no intrinsic child indentation, so its handle and its first block's handle would coincide; the editing view indents quote content to give - each nesting level its own gutter column (lists and cells indent natively) */ + each nesting level its own gutter column (lists and cells indent natively). + The quote bar (Markdown/Bootstrap convention) makes the container visible - + a normalized blockquote > p would otherwise read as a plain paragraph */ .rdfa-editor-content blockquote { - padding-left: 1.75em; + padding-left: 2em; + border-left: 4px solid #e0e0e0; + color: #616161; } /* the editing-DOM run wrapper (mixed flow content, e.g.
  • text
      ...) renders