diff --git a/.packages/@do-md/core/src/editor/controller/EditorController.ts b/.packages/@do-md/core/src/editor/controller/EditorController.ts index c34b527..6f07a95 100644 --- a/.packages/@do-md/core/src/editor/controller/EditorController.ts +++ b/.packages/@do-md/core/src/editor/controller/EditorController.ts @@ -27,7 +27,8 @@ import { getVisibleDomText } from "./lib/getVisibleDomText"; import { getOffsetTop } from "./lib/getOffsetTop"; import { getDomByCursor } from "./lib/getDomByCursor"; import { getRenderDomByID } from "./lib/getRenderDomByID"; -import { commandKey } from "./lib/commandKey"; +import { commandKey, foreignCommandKey } from "./lib/commandKey"; + import { matchesNewlineKey } from "./lib/matchesNewlineKey"; import { matchesLetterKey } from "./lib/matchesLetterKey"; @@ -1526,6 +1527,110 @@ export class EditorController { this.parseInCursor_(); }; + /** + * Build the whole-document DOM selection ourselves — the select-all + * terminal state's DOM projection. + * ------------------------------------------------------------------- + * Why not the engine's own SelectAll: WebKit's native command collapses + * to the document tail when the editable root's first child is a + * non-editable overlay (custom caret / remote-cursor layers, which hosts + * legitimately portal into the root) — measured on WebKit 26 with a + * minimal contenteditable; Blink is unaffected. + * Why visible-leaf endpoints and not container boundaries: WebKit keeps + * a container-endpoint range (selectNodeContents) as selection DATA but + * paints no highlight for it at all — pixel-verified on the system + * WKWebView, the user experiences "Cmd+A does nothing". Endpoints must + * sit on text nodes for WebKit to paint, and they must be *visible* text + * nodes: coordinates resolved blindly from the model would land inside + * hidden syntax runs (the display:none MdSymbol "# " of a heading). + * Blink paints either form; the visible-leaf range measures stable on + * both engines. + * Used by the Cmd/Ctrl+A keydown and by the replay pass to restore the + * terminal state after undo / a re-render dropped the anchors. + * The DOM write is deferred by one macrotask: WebKit keeps a selection + * installed synchronously inside a (trusted, preventDefaulted) keydown + * handler as DATA but never paints its highlight — the exact same range + * re-applied outside the key event paints fine (pixel-verified on the + * system WKWebView; Blink paints both). One task of delay stays under a + * frame, so there is no visible unselected window. + */ + /** + * First and last render block among the root's children. Skips + * non-content scaffolding (overlay divs hosts portal into the root — + * custom caret, remote-cursor layers): a whole-document range built from + * visible leaves legitimately excludes them, so any coverage probe using + * first/lastElementChild would fail against our own selection. + */ + private firstLastRenderBlocks_(): { + firstBlock: Element | null; + lastBlock: Element | null; + } { + let firstBlock: Element | null = null; + let lastBlock: Element | null = null; + for (const el of this._textAreaDom_.children) { + if (!el.hasAttribute("data-render-id")) continue; + if (!firstBlock) firstBlock = el; + lastBlock = el; + } + return { firstBlock, lastBlock }; + } + + private selectAllDom_() { + setTimeout(() => this.applySelectAllDom_(), 0); + } + + private applySelectAllDom_() { + // The deferral window is one task; if the terminal state was already + // cancelled in between (a click landed an ordinary cursor), applying + // the select-all projection now would stomp on the user's action. + if (!this._editorStore_.cursorInfo_.all_) return; + const selection = document.getSelection(); + if (!selection) return; + const rootEl = this._textAreaDom_; + const { firstBlock, lastBlock } = this.firstLastRenderBlocks_(); + const range = document.createRange(); + if (firstBlock && lastBlock) { + // First/last VISIBLE text leaf. The parentElement display check + // matches how MdSymbol hides syntax (display:none directly on + // the leaf's parent span); checkVisibility additionally covers + // hidden ancestors where supported. + const isVisible = (el: Element) => + (el as HTMLElement).checkVisibility?.() ?? + window.getComputedStyle(el).display !== "none"; + const visibleTextIn = (scope: Element, last: boolean) => { + const walker = document.createTreeWalker( + scope, + NodeFilter.SHOW_TEXT, + { + acceptNode: (n) => + n.parentElement && isVisible(n.parentElement) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }, + ); + if (!last) return walker.nextNode(); + let found: Node | null = null; + while (walker.nextNode()) found = walker.currentNode; + return found; + }; + const firstText = visibleTextIn(firstBlock, false); + const lastText = visibleTextIn(lastBlock, true); + if (firstText && lastText) { + range.setStart(firstText, 0); + range.setEnd(lastText, lastText.textContent?.length ?? 0); + } else { + // Empty blocks (


) hold no text leaves — fall back + // to block boundaries. + range.setStartBefore(firstBlock); + range.setEndAfter(lastBlock); + } + } else { + range.selectNodeContents(rootEl); + } + selection.removeAllRanges(); + selection.addRange(range); + } + /** * Reads the cursor position; skipped after a composition input. * * */ @@ -1547,8 +1652,18 @@ export class EditorController { // Enter, Tab, IME). Command-level shortcuts are registered on window keydown // by the commands layer or by the host application. - // undo/redo - if ((e.metaKey || e.ctrlKey) && matchesLetterKey(e, "z")) { + // undo/redo — the platform's primary command modifier only. The old + // (metaKey || ctrlKey) form swallowed macOS's Ctrl+letter system + // bindings and Windows's Win+letter chords alike; shiftKey stays free + // (Cmd/Ctrl+Shift+Z = redo), every other extra modifier opts out. + // The letter matches by key cap (matchesLetterKey), not by position, + // so the binding survives non-QWERTY layouts. + if ( + commandKey(e) && + !foreignCommandKey(e) && + !e.altKey && + matchesLetterKey(e, "z") + ) { e.preventDefault(); if (this._editorStore_.duringComposition) { return; @@ -1561,32 +1676,93 @@ export class EditorController { return; } - // Select-all, Cmd/Ctrl+A — enter the select-all terminal state - // (cursorInfo_.all_). - // We deliberately do not preventDefault: let the browser select everything - // natively and keep its selection highlight (CustomCursor hides itself - // whenever the selection is not collapsed, leaving the highlight to the - // browser). The store side only flags the terminal state: subsequent editing - // operations (delete / typing / paste / cut / Enter / IME) go through the - // whole-document primitive replaceAllContent_ and no longer depend on DOM - // coordinates — DOM coordinates cannot address the leading / trailing - // serialization scaffolding (deleting through them leaves stubs behind), and - // during a chunked load they do not cover the unparsed part at all. - // getCursorInfo() cannot be reused here: on select-all the start container - // lands on the root node, so the marker cannot be inserted into the first - // block (see the handleCopy / handleCut comments). setSelectAll_ reads - // straight from renderData instead — start = the first render block at - // offset 0, end = the full length of the last render leaf. - // Known gap: the mobile long-press menu's "Select All" does not go through - // keydown, so it does not enter the terminal state yet (left as is until we - // can observe the real path and settle on an inference rule). - if ((e.metaKey || e.ctrlKey) && matchesLetterKey(e, "a")) { + // Select-all (⌘A on macOS, Ctrl+A elsewhere) — enter the select-all + // terminal state (cursorInfo_.all_). + // The modifier check is platform-exact. On macOS, Ctrl+A is the + // system's "move to beginning of paragraph" text binding; the old + // (metaKey || ctrlKey) form hijacked it into the terminal state, and + // the replayed whole-document range then polluted the selection the + // native caret motion acted on — teleporting the caret to the start + // of the document (issue #27, first report). Shift/Alt variants + // (⌘⇧A …) have no select-all semantics anywhere and must not raise + // the flag either: the browser performs no native select-all for + // them, so the echo handshake would dangle on a non-selected + // document. The letter matches by key cap (matchesLetterKey), not by + // position, so the binding survives non-QWERTY layouts. + // We preventDefault and build the whole-document selection ourselves + // (selectAllDom_). The engine's own SelectAll used to be trusted here, + // but it is not trustworthy: WebKit's native SelectAll command + // collapses the selection to the document tail whenever the editable + // root's first child is a non-editable overlay — exactly what hosts + // mount (custom caret, remote-cursor layers) — which the user sees as + // "Cmd+A flashes, then the selection vanishes" on the desktop + // WKWebView (issue #27, second report; feature-isolated in a minimal + // contenteditable: plain/hidden-syntax DOMs select fine, adding a + // contenteditable=false absolute first child alone breaks it; Blink + // is unaffected). preventDefault also stops the key equivalent from + // reaching the desktop menu's native selectAll: — the same broken + // engine command via another door. + // The store side flags the terminal state: subsequent editing + // operations (delete / typing / paste / cut / Enter / IME) go through + // the whole-document primitive replaceAllContent_ and do not depend + // on DOM coordinates — DOM coordinates cannot address the leading / + // trailing serialization scaffolding (deleting through them leaves + // stubs behind), and during a chunked load they do not cover the + // unparsed part at all. + // getCursorInfo() cannot be reused here: on select-all the start + // container lands on the root node, so the marker cannot be inserted + // into the first block (see the handleCopy / handleCut comments). + // setSelectAll_ reads straight from renderData instead — start = the + // first render block at offset 0, end = the full length of the last + // render leaf. + // Known gap: the mobile long-press menu's "Select All" does not go + // through keydown, so it does not enter the terminal state yet (left + // as is until we can observe the real path and settle on an + // inference rule); a desktop menu-item "Select All" click does not + // either — hosts should route their menu item through the store + // (the DOMD desktop Edit menu does exactly that). + if ( + commandKey(e) && + !foreignCommandKey(e) && + !e.shiftKey && + !e.altKey && + matchesLetterKey(e, "a") + ) { + // During IME composition the DOM belongs to compositionSnapshot_ — + // hands off entirely, matching the previous behaviour (the store + // setters below are composition-guarded no-ops anyway). + if (this._editorStore_.duringComposition) return; + e.preventDefault(); this._editorStore_.applyPendingText_(); this._editorStore_.setSelectAll_(); + this.selectAllDom_(); + return; } + // System text-navigation chords (NSStandardKeyBindingResponding: + // Ctrl+A/E = paragraph start/end, Ctrl+N/P = next/previous line, + // Ctrl+F/B = forward/backward), implemented natively by WebKit and + // partially by Blink on macOS. They move the caret exactly like the + // bare keys in keysThatMoveCursor but arrive as Ctrl+letter chords, + // so they need the same speculative-input flush — without it the + // armed pending timer wakes up after the caret has left and writes + // the stale snapshot's cursor back (issue #27: "Ctrl+N/P land + // unreliably and interfere with ↑/↓"). Editing-type bindings + // (Ctrl+K/D/H/T/O/Y) mutate the DOM and travel through the + // beforeinput machinery instead; they are deliberately not listed. + // No platform gate: where a chord has no system binding the flush is + // a no-op (applyPendingText_ short-circuits on empty pending; Ctrl+A + // on Windows never reaches this point — the select-all branch above + // flushes and returns). + const isSystemNavigationChord = + e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + ["a", "e", "n", "p", "f", "b"].includes(e.key); + // Check whether this is one of the cursor-moving keys - if (keysThatMoveCursor.includes(e.key)) { + if (keysThatMoveCursor.includes(e.key) || isSystemNavigationChord) { this._editorStore_.applyPendingText_(); } @@ -1764,14 +1940,20 @@ export class EditorController { if (this._editorStore_.selectAllEchoPending_) { this._editorStore_.selectAllEchoPending_ = false; if (this._editorStore_.cursorInfo_.all_ && info.length === 2) { - const rootEl = this._textAreaDom_; - const firstEl = rootEl.firstElementChild; - const lastEl = rootEl.lastElementChild; + // Probe the first/last RENDER blocks, not + // first/lastElementChild: the selection being echoed is the + // one selectAllDom_ builds from visible text leaves, which + // legitimately excludes the root's non-content scaffolding + // (overlay divs) — probing scaffolding would fail against our + // own selection and let this very reading demote the terminal + // state to a plain range. + const { firstBlock, lastBlock } = + this.firstLastRenderBlocks_(); if ( - firstEl && - lastEl && - selection.containsNode(firstEl, true) && - selection.containsNode(lastEl, true) + firstBlock && + lastBlock && + selection.containsNode(firstBlock, true) && + selection.containsNode(lastBlock, true) ) { return; } @@ -2421,6 +2603,39 @@ export class EditorController { return; } + // —— Select-all terminal state: whole-container terms only —— + // all_'s (uuid, offset) pair only approximates "the whole document", + // so replaying it through the leaf-coordinate machinery below is + // wrong twice over: the strict endpoint-equality check can never + // confirm an existing whole-document selection (its endpoints snap + // to visible positions, ours resolve into hidden syntax runs), and + // the rebuilt range would then fight the selection built at keydown + // — every replay pass rewriting the live selection is exactly the + // flicker class issue #27 reported. + // 1. Already in place — a live range touching both the first and the + // last render block (the same coverage geometry the + // selectionchange echo guard uses; the probe must skip the root's + // non-content scaffolding divs, which a rebuilt range + // legitimately excludes) → zero side effects. + // 2. Otherwise (undo restored the terminal state, a re-render + // dropped the selection anchors) rebuild via selectAllDom_. + // No scrolling either way: select-all must not move the viewport. + if (cursorInfo.all_) { + const { firstBlock, lastBlock } = this.firstLastRenderBlocks_(); + if ( + liveRangeInEditor && + liveSelection && + firstBlock && + lastBlock && + liveSelection.containsNode(firstBlock, true) && + liveSelection.containsNode(lastBlock, true) + ) { + return; + } + this.selectAllDom_(); + return; + } + // Scope uuid lookups to THIS editor's DOM: with multiple editors on // one page rendering clones of the same document (identical uuids — // e.g. the CRDT playground), a document-wide query would restore the diff --git a/.packages/@do-md/core/src/editor/controller/lib/commandKey.ts b/.packages/@do-md/core/src/editor/controller/lib/commandKey.ts index 3072e62..bede51e 100644 --- a/.packages/@do-md/core/src/editor/controller/lib/commandKey.ts +++ b/.packages/@do-md/core/src/editor/controller/lib/commandKey.ts @@ -1,9 +1,26 @@ -export function commandKey(e: KeyboardEvent) { - const isMac = /Mac/i.test( +function isMacPlatform() { + return /Mac/i.test( // @ts-expect-error navigator.userAgentData?.platform || - navigator.platform || - navigator.userAgent, + navigator.platform || + navigator.userAgent, ); - return isMac ? e.metaKey : e.ctrlKey; +} + +/** The platform's primary command modifier: ⌘ on macOS, Ctrl elsewhere. */ +export function commandKey(e: KeyboardEvent) { + return isMacPlatform() ? e.metaKey : e.ctrlKey; +} + +/** + * The other platform's command modifier: Ctrl on macOS, ⌘/Win elsewhere. + * Kernel shortcuts must not match while it is held. On macOS the Ctrl+letter + * space belongs to the system text bindings (NSStandardKeyBindingResponding: + * Ctrl+A = paragraph start, Ctrl+E = paragraph end, Ctrl+N/P = next/previous + * line, ...) that WebKit — and partially Blink — implement natively; treating + * Ctrl as "just another command key" swallows them (issue #27). Mirrors the + * primary/foreign split used by the @do-md/commands keymap. + */ +export function foreignCommandKey(e: KeyboardEvent) { + return isMacPlatform() ? e.ctrlKey : e.metaKey; }