From d26f2acecc7801105c109e3113e9b2ed099d179e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 08:46:21 +0300 Subject: [PATCH 1/5] Add slash menu and markdown input rules (PR #10 port) Ported from the notion-affordances branch onto the content-model era: one priority-raised ixsl:onbeforeinput dispatcher (src/input.xsl) intercepts printable-char triggers and next-matches into undo's typing coalescer, so plain typing still snapshots. Triggers act on the HOST the caret sits in and every conversion is content-model-gated - a shorthand whose result the model rejects stays literal text. - / in an empty host opens a filterable, keyboard-navigable menu showing only the commands the context admits (conversions for text-block hosts, Quote where the wrap is legal, lists where the model places one, figure/table always - routed to the existing dialogs); menu popups position via show-at-caret over the refactored show-at-point. - Markdown shorthands in a paragraph host: #/##/### + space, -/* + space, 1. + space, ``` -> convert in place; '> ' WRAPS in a blockquote (blockquote > p - converting the p itself would be invalid). Undo restores the literal marker in one step. - convert-block: an element-level caret on the block itself dangled after replaceWith (PR #10's fix) - restore only nodes that move. New notion.mjs suite (31 assertions incl. context filtering in list items and flow cells, nested-host conversion, I1-I5 invariants). Co-Authored-By: Claude Fable 5 --- rdfa-editor.css | 43 ++++ src/edit.xsl | 14 +- src/index.xsl | 5 +- src/input.xsl | 414 +++++++++++++++++++++++++++++++++++++++ src/overlay.xsl | 18 +- src/undo.xsl | 2 +- tests/browser/notion.mjs | 285 +++++++++++++++++++++++++++ tests/browser/run.mjs | 2 +- 8 files changed, 774 insertions(+), 9 deletions(-) create mode 100644 src/input.xsl create mode 100644 tests/browser/notion.mjs 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..1ed3921 100644 --- a/src/edit.xsl +++ b/src/edit.xsl @@ -282,6 +282,7 @@ version="3.0"> + @@ -1716,9 +1717,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 ()"/> @@ -2013,7 +2017,7 @@ version="3.0"> - + @@ -2023,12 +2027,14 @@ version="3.0"> + id('table-dialog', ixsl:page()), id('find-dialog', ixsl:page()), + id('slash-menu', ixsl:page())"> + diff --git a/src/index.xsl b/src/index.xsl index 1f2295c..d31bdad 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"> + @@ -51,7 +53,8 @@ version="3.0"> + 'breadcrumbLeaf', 'draggedSectionHeading', 'findNode', 'tocRoot', + 'slashHost')"> diff --git a/src/input.xsl b/src/input.xsl new file mode 100644 index 0000000..2fc8a8d --- /dev/null +++ b/src/input.xsl @@ -0,0 +1,414 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/undo.xsl b/src/undo.xsl index 02609ee..442451b 100644 --- a/src/undo.xsl +++ b/src/undo.xsl @@ -181,7 +181,7 @@ version="3.0"> + 'draggedSectionHeading', 'slashHost')"> diff --git a/tests/browser/notion.mjs b/tests/browser/notion.mjs new file mode 100644 index 0000000..a247a30 --- /dev/null +++ b/tests/browser/notion.mjs @@ -0,0 +1,285 @@ +// 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, inserting after the TOP-LEVEL block +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.figureAfterTable', await page.evaluate(() => { + const table = document.querySelector('#content > table'); + return table?.nextElementSibling?.tagName === 'FIGURE'; +})); +await clean('slash.figure'); + +// ---------------------------------------------------------------- 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))], From f0783c1baf26883dddce9ca600c48d6fc4c9e36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 08:51:04 +0300 Subject: [PATCH 2/5] Add canonical copy/cut and type-to-replace for cross-host selections Copy of a selection spanning hosts now puts the STORAGE form on the clipboard instead of the editing DOM: cloneContents of the region-clamped range runs the canonical + cm-normalize pipeline (paste in reverse), so the HTML flavor carries no chrome, contenteditable or marker classes but keeps RDFa attributes - annotated content round-trips between documents. Cut adds the delete machine (one region-keyed undo entry). Within-host copy stays native. The clamp steps are factored into local:clamped-range, shared by the delete machine and copy. Typing a printable character over a cross-host selection now replaces it: the delete machine collapses the caret into a host and the character lands there (local:insert-text-at-caret) - one gesture, one undo entry, from host focus and from the page background alike. Enter/Tab/paste stay suppressed. select.mjs: type-to-replace (stage 2 and sweep-with-seam), canonical copy assertions (no editing ephemera, RDFa kept, plain-text flavor), native within-host copy, cut with merge + one-undo restore. Co-Authored-By: Claude Fable 5 --- src/edit.xsl | 31 ++++++++-- src/select.xsl | 119 +++++++++++++++++++++++++++++++++------ tests/browser/select.mjs | 88 ++++++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 25 deletions(-) diff --git a/src/edit.xsl b/src/edit.xsl index 1ed3921..d5493b7 100644 --- a/src/edit.xsl +++ b/src/edit.xsl @@ -581,17 +581,27 @@ 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) --> - + + + + + + + + + @@ -756,6 +766,17 @@ version="3.0"> + + + + + + + + + diff --git a/src/select.xsl b/src/select.xsl index 45ea91a..318557d 100644 --- a/src/select.xsl +++ b/src/select.xsl @@ -101,6 +101,88 @@ version="3.0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + @@ -2116,18 +2118,13 @@ version="3.0"> - - - - - - - - - - - - + + + + diff --git a/src/index.xsl b/src/index.xsl index d31bdad..c3f0696 100644 --- a/src/index.xsl +++ b/src/index.xsl @@ -52,7 +52,7 @@ version="3.0"> LinkedDataHub's window.LinkedDataHub); reached everywhere via local:editor-state() --> diff --git a/src/input.xsl b/src/input.xsl index 2fc8a8d..9d2bbcf 100644 --- a/src/input.xsl +++ b/src/input.xsl @@ -294,7 +294,7 @@ version="3.0"> - @@ -324,7 +324,7 @@ version="3.0"> - + @@ -338,8 +338,18 @@ version="3.0"> - + + + + + + + + + + + diff --git a/src/tables.xsl b/src/tables.xsl index b52ed7a..3c2aacf 100644 --- a/src/tables.xsl +++ b/src/tables.xsl @@ -135,8 +135,8 @@ version="3.0"> - + @@ -197,18 +197,13 @@ version="3.0"> - - - - - - - - - - - - + + + + diff --git a/src/undo.xsl b/src/undo.xsl index 442451b..501cdca 100644 --- a/src/undo.xsl +++ b/src/undo.xsl @@ -180,7 +180,7 @@ version="3.0"> diff --git a/tests/browser/notion.mjs b/tests/browser/notion.mjs index a247a30..9dd8f7f 100644 --- a/tests/browser/notion.mjs +++ b/tests/browser/notion.mjs @@ -171,7 +171,8 @@ await page.waitForTimeout(120); assert('slash.cellContext', (await shownCommands()).length === 10); await page.keyboard.press('Escape'); -// figure command routes to the existing dialog, inserting after the TOP-LEVEL block +// 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('/'); @@ -182,12 +183,42 @@ 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.figureAfterTable', await page.evaluate(() => { - const table = document.querySelector('#content > table'); - return table?.nextElementSibling?.tagName === 'FIGURE'; +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(); From 262f732fa2590cdf55f9f2feb04e72282ea6cbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 18:24:13 +0300 Subject: [PATCH 5/5] Add Docs-style cross-block selection gestures The browser confines a native drag-selection to the contenteditable host it starts in, so no mouse gesture could create a multi-block selection (two-stage Ctrl+A was the only real one; select.mjs masked the gap by building ranges programmatically). Synthesize the gestures in select.xsl: mousedown arms a sweep anchor (caretRangeFromPoint, caretPositionFromPoint fallback), mousemove rebuilds the selection anchor-to-pointer via setBaseAndExtent once the pointer leaves the anchor host (backward selections keep the anchor fixed), clamped to one region, with viewport nudging replacing the dead native autoscroll. Shift+Click extends from the standing anchor and re-arms for Shift+drag; Shift+Up/Down extend block-granularly past host edges (native within the host). In-host drags stay native, the drag handle never arms a sweep, sweep state clears on mouseup/dragstart/undo restore. Downstream machinery (delete machine, type-to-replace, canonical copy/cut) consumes the selections unchanged. New select.mjs cases drive the gestures with real mouse events. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- src/edit.xsl | 25 +++ src/index.xsl | 3 +- src/navigate.xsl | 3 + src/select.xsl | 319 +++++++++++++++++++++++++++++++++++++-- src/undo.xsl | 2 +- tests/browser/select.mjs | 225 +++++++++++++++++++++++++++ 7 files changed, 565 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ca12fca..c064720 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Modules under `src/`: - **annotate.xsl** — the unified right-click dispatcher (edit vs create), `local:apply-annotation` (single write path for RDFa attributes), shared `local:wrap-range`/`local:unwrap-element` (used by annotations AND inline formatting), invalid-selection flash, the shared output modal (`local:show-output`). - **edit.xsl** — the XHTML editor. **Recursive editability init** (`local:init-block`): an element is a *text host* (contenteditable) while its content model allows text and it holds no block children; mixed flow containers (`li`/`td`/`dd`/… with blocks) and structural containers (blockquote, lists, figure, table) lock their own markup and recurse into their parts (`figure` is always composite: image island + caption host); `img` never editable but given `tabindex="-1"` so a block image is a focusable **navigation island**. **Run wrappers**: the stray inline runs of a mixed flow container (`
  • text
  • `) get an ephemeral `p.rdfa-editor-run` host (`local:wrap-stray-runs`) so they stay editable — unwrapped again at canonicalization (C11), so mixed content round-trips byte-identical; structural gestures promote a wrapper to a real paragraph. **Load-init normalization**: each region is probed for invalid nesting (`cm:valid-nesting`, stray text in structural containers, stray region-level inline) and rebuilt through `mode="cm-normalize"` only when invalid (the valid case is zero-churn; host-page node references into a rewritten region go stale, same as undo restore). First-child chrome injection is a **top-level-only** affordance (`span[@data-role='chrome']`, hover-revealed via CSS) — nested hosts never carry drag handles. The Enter/Backspace state machine (split/merge primitives with explicit caret placement): splitting a run wrapper *promotes* it (marker class off); E4a — Enter on the empty last item of a *nested* list outdents one level (progressive), E4b — top-level exits to a paragraph; B2 merges text blocks, B2b descends into a preceding non-composite container's (blockquote/list) last host — bare text in the container itself would be invalid — while tables/figures stay hard boundaries (B3); B4 merges an `li` into the *visually preceding line* (`local:merge-host-in` — the last host of the previous item, unless it sits inside a nested table/figure: composites are hard boundaries at any depth, the gesture stays inert), B4b outdents the first item of a nested list, B4c merges the first item of a container-nested list (cell, quote, dd) into the preceding line inside the container — dissolving the emptied list and collapsing the container back to a text host — B7 exits the first block of a blockquote upward (emptied quote removed); E4b anchors the list-exit paragraph *before* removal, so it lands where the list was (inside the cell/quote for nested lists); E6 anchors the caption-exit paragraph after the figure/table itself (which may be nested). **Ancestor walks clamp at the region root** (`[exists(local:block-of(.))]` on `local:item-of`, the Tab cell lookup and the quote-toggle's blockquote lookup) — a host-page li/td/blockquote wrapping an embedded region is never touched. A focused image island deletes its *figure* when it has one, else just the image — never the whole containing list/table. **Tab/Shift+Tab** indent/outdent list items (`local:list-indent`/`local:list-outdent`: indent moves the item into a trailing-or-fresh nested list *inside the previous item* per `ul → (li)+`; outdent moves it after the container item, demoting followers into a nested list inside it; `local:collapse-container` reverts an emptied container to a text host; extremes flash) — the innermost context wins over table-cell traversal (li-in-td indents, a table nested in an item traverses). The **quote toggle** (`format-quote`) wraps the current host block in a `blockquote` where the parent admits one / unwraps all children out (refused with a flash when the quote carries RDFa — triples would drop); the block-type select is **host-based** (`local:current-host`): a paragraph inside a quote converts alone, non-convertible hosts disable it. The **insert buttons** (+¶, lists) place the new block per the content model (`local:insert-block-at-caret`): after the caret's host when its parent admits the kind, *inside* a leaf flow host otherwise (a list inserted in a cell nests in the cell; in a list item it becomes a sublist), after the top-level block as a last resort. **Paste** is cm-driven: clipboard HTML runs `mode="canonical"` + `mode="cm-normalize"`, then blocks land *inside* a mixed flow host (`local:paste-into-flow-host` — head/tail runs wrapped, host re-inited as container), or as siblings of an inline-only host via split-and-thread (when `cm:allows-child` permits), or flatten to text (caption, dt). Drag-and-drop ported from LinkedDataHub's `client/block.xsl` (handle-gated `draggable`, midpoint before/after marks). Arrow-key block crossing walks `local:nav-targets` (editable hosts **plus** block images, in document order): a host gets a caret, an image is *selected* (`local:select-image` focuses it, clearing the caret) — so images are never skipped; on a selected image, arrows step to the adjacent target and Backspace/Delete removes the whole figure (no confirm, undo-covered). Table cell keys (Tab/Enter) and the table dialog live in **tables.xsl**. - **tables.xsl** — table blocks as a composite kind: the insert dialog (rows×cols + header-row + caption, modeled on the figure dialog), positional row/column operations (`local:op-insert-row`/`-column`, `-delete-row`/`-column`) gated behind `local:has-spans` (disabled on pasted `colspan`/`rowspan` grids — positional edits would corrupt a spanned table) and toolbar-synced via `local:sync-table-toolbar` riding `local:update-breadcrumb`, plus `local:table-tab`/`local:table-enter` cell traversal that appends a body row at the bottom edge. Cells are `%Flow;` containers: every caret landing resolves through `local:first-host-in`/`local:last-host-in`, so a cell holding blocks lands the caret in its first/last editable host; the Tab dispatcher (edit.xsl) passes the *cell* as the traversal host even when the caret sits in a nested block. Deleting the last body row/column is a no-op — the confirm-guarded delete-block button removes the whole table. **Undo hazard:** a chrome `span` serialized as a direct child of `` is foster-parented out by the HTML fragment parser on `innerHTML` restore, so `local:restore-snapshot` drops stray region-child chrome and re-injects (idempotent) before caret resolution. -- **select.xsl** — region-scoped select-all and cross-host selection delete (dispatch lives in edit.xsl's keydown/body/paste templates, mirroring the tables.xsl split). **Two-stage Ctrl/Cmd+A** (Docs-style): stage 1 stays native — the browser scopes select-all to the focused host; stage 2 (host already fully selected per the chrome-aware `local:at-*` probes, or empty, or the selection already spans hosts) selects all blocks of the region as a **document-level range** (`local:select-region`) — it paints natively across host boundaries and never reaches the host page. Ctrl+A away from a caret also selects editor content, never the page: from the body it targets the swept region, else `local:active-root()` (selection anchor → last focused host → first region), and a focused image island is its own fully-selected unit (stage 2 directly, with Backspace/Delete then running the delete machine instead of the island's figure-delete); native page select-all happens only when no region exists. One **delete machine** (`local:delete-cross-host-selection`) serves stage-2 selections and mouse sweeps alike (`local:selection-crosses-hosts()` gates it), fired by Backspace/Delete from host *or* body focus — a sweep from the page background leaves focus on body. Deletion is **block-granular**, never one raw `deleteContents` across the range: fully covered blocks are removed whole; partial edge hosts get sub-range deletes scoped *inside* the host; composites holding a range boundary never lose structure — their covered cells are cleared and flow cells collapse back to text hosts (`local:clear-host`; B3/B4 doctrine at partial coverage) while a *fully* covered composite is removed whole; non-composite edge remnants merge Docs-style via `local:merge-into-previous` with the caret at the seam (never with `pre` — B6); emptied structural containers are pruned (`local:prune-husks`), chrome is re-injected (idempotent), and an emptied region is reseeded with a fresh `p` host (`local:seed-region`). The range is **clamped to a single region** (the start's, else the first swept; `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. +- **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). @@ -67,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, type-to-replace, canonical copy/cut, composite/clamp semantics), `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`). +- 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/src/edit.xsl b/src/edit.xsl index f2946cb..9b7e1ea 100644 --- a/src/edit.xsl +++ b/src/edit.xsl @@ -580,6 +580,18 @@ version="3.0"> + + + + + + + + + + + + + @@ -2148,10 +2169,13 @@ version="3.0"> + + + @@ -2175,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 c3f0696..d6314f5 100644 --- a/src/index.xsl +++ b/src/index.xsl @@ -54,11 +54,12 @@ version="3.0"> + 'slashHost', 'sweepAnchorNode', 'sweepAnchorHost', 'sweepRegion')"> + 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/select.xsl b/src/select.xsl index 318557d..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"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +