From 06a69053ecc29c8215a3c91fc648b93ec68897fd Mon Sep 17 00:00:00 2001 From: jintao-wang Date: Sun, 13 Sep 2026 17:49:41 +0800 Subject: [PATCH] fix(editor): Cmd+A on WebKit, and macOS Ctrl navigation chords (#27) Select-all only flagged the terminal state and left the DOM selection to the engine, and the kernel treated Ctrl as an alias of the command modifier. Both assumptions break on macOS WebKit: - WebKit's native SelectAll collapses to the document tail whenever the editable root's first child is a non-editable overlay -- exactly what hosts portal in (custom caret, remote-cursor layers). The user sees "Cmd+A flashes, then the selection vanishes". Blink is unaffected. - Ctrl+A/E/N/P/F/B are system text bindings (NSStandardKeyBindingResponding: paragraph start/end, next/previous line, forward/backward). Matching Ctrl+A as select-all hijacked paragraph-start into the terminal state, and the replayed whole-document range then teleported the caret to the top of the document. Ctrl+N/P landed unreliably and fought the arrow keys. Changes: - commandKey: split out foreignCommandKey (Ctrl on macOS, Cmd/Win elsewhere). Kernel shortcuts (undo/redo, select-all) now require the primary modifier and opt out on the foreign one, mirroring the @do-md/commands keymap. - Build the whole-document selection ourselves (selectAllDom_) instead of trusting the engine command: endpoints on the first/last VISIBLE text leaf, because WebKit paints no highlight for a container-endpoint range and blind model coordinates resolve into hidden syntax runs. The DOM write is deferred one macrotask -- WebKit keeps a selection installed inside a preventDefaulted keydown as data but never paints it -- and bails if the terminal state was cancelled in the meantime. - preventDefault on select-all, which also stops the desktop menu's native selectAll: from reaching the same broken engine command. - Replay pass: for the terminal state, confirm coverage of the first/last render block and return, otherwise rebuild via selectAllDom_, instead of replaying (uuid, offset) through the leaf-coordinate machinery -- that rebuilt range fought the live selection every pass (the reported flicker). - Probe the first/last *render* block (data-render-id) rather than first/lastElementChild: overlay scaffolding is legitimately outside our own selection, so probing it made the echo guard demote the terminal state. - Flush pending speculative input on the system navigation chords too, like the bare cursor keys: otherwise the armed pending timer wakes up after the caret has moved and writes the stale cursor back. --- .../src/editor/controller/EditorController.ts | 277 ++++++++++++++++-- .../src/editor/controller/lib/commandKey.ts | 27 +- 2 files changed, 268 insertions(+), 36 deletions(-) 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; }