UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,026 lines (995 loc) 86 kB
/* MIT License Copyright (c) 2020 Phil Plückthun, Copyright (c) 2021 Formidable Copyright (c) 2026 Material-UI SAS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // Forked from https://github.com/FormidableLabs/use-editable // Changes (see git history and inline comments for rationale): // - Linting, formatting, tests, and React 19 compatibility (lazy useState, useRef MutationObserver, SSR guards) // - Performance: TreeWalker-based makeRange/getPosition, deduped toString() calls, getLineInfo walks only neighboring lines // - Firefox quirks: preserve pendingContent across rapid keydowns, refresh baseline after controlled edits, repair line-merges, route plaintext keys through edit.insert in the contentEditable="true" fallback // - Undo stack: record repaired (not raw) content, allow tracking before first flush, bypass 500ms dedup for structural edits (Enter) // - Repeat-key flush debouncing so syntax re-highlight fires once on key release // - Resync (instead of block) on stale-DOM arrow keys so navigation isn't eaten after a pending edit // - adjustCursorAtNewlineBoundary applied to all programmatic caret placements; getState() returns an empty snapshot pre-mount // - New `minColumn` option: skip clipped indent gutter via arrow navigation, click, and tab-focus; Backspace on a fully-clipped blank line clears the whole hidden indent (caret stays on the line at column 0) // - New `minRow`/`maxRow`/`onBoundary` options: arrow navigation past the visible region invokes the callback (and falls through natively when provided so hosts can expand collapsed regions) // - New `caretSelector` option: synchronous horizontal line-wrap and post-arrow rAF snap to lift the caret out of inter-line gap text nodes (e.g. `\n` between `.line` spans) // - Override copy/cut: write `Range.toString()` for `text/plain` (avoids duplicated newlines from block-level line wrappers) and an inline-styled `<pre>` clone for `text/html`; strip the clipped indent gutter from both payloads when `minColumn` is set import * as ReactDOM from 'react-dom'; import { adjustCursorAtNewlineBoundary, asElement, getCurrentRange, getLineInfo, getOffsetAtLineColumn, getPosition, isPlaintextInputKey, isUndoRedoKey, makeRange, repairUnexpectedLineMerge, restoreSelection, setCurrentRange, toString } from "./useEditableUtils.mjs"; import { cloneRangeWithInlineStyles } from "./cloneRangeWithInlineStyles.mjs"; import { extractLeadingPerLine, stripLeadingPerLine, stripLeadingPerLineDom } from "./stripLeadingPerLine.mjs"; const observerSettings = { characterData: true, characterDataOldValue: true, childList: true, subtree: true }; // Cross-instance batching for the `getComputedStyle` read + conditional // inline-style writes that happen during each editable's setup. // // Pages like the Material UI component docs render ~30 demos at once. // The previous implementation interleaved a write (the layout effect's // own `element.style.whiteSpace = ...` / `tabSize` settings, plus the // implicit invalidation from the preceding `contentEditable` write) // with a read (`getComputedStyle(element).whiteSpace`) inside each // instance's effect. That forced the browser to flush a fresh style // recalc on every iteration — 30 recalcs in a row during a single // commit. // // By queuing each instance's read+write block into a single microtask // we run all of the reads (which share one recalc) followed by all the // writes, instead of interleaving them with the other instances'. // // `contentEditable` itself is still set synchronously inside the layout // effect: the keyboard/paste/focus handlers bound in the same effect // assume the host element is already editable when the commit returns, // so any input that lands in the same frame as the mount (autofocus, // programmatic focus, a queued keystroke) is routed through the // plaintext-only path instead of falling back to native contenteditable // behavior. // // The cleanup-side restore (`whiteSpace` + `contentEditable` back to // their pre-mount values) runs synchronously inside the layout-effect // teardown, gated by `element.isConnected` so detached hosts skip the // write. The in-flight mount-side microtask is cancelled via // `styleSetupCancelled` so there's no race. let pendingEditableStyleTasks = null; function scheduleEditableStyleTask(task) { if (pendingEditableStyleTasks === null) { pendingEditableStyleTasks = [task]; queueMicrotask(() => { const tasks = pendingEditableStyleTasks; pendingEditableStyleTasks = null; for (let i = 0; i < tasks.length; i += 1) { tasks[i](); } }); } else { pendingEditableStyleTasks.push(task); } } // Computed-style properties inlined onto each element in the copied // HTML fragment so external paste targets render with the same syntax // highlighting without needing our stylesheet. const CLIPBOARD_ELEMENT_STYLE_PROPS = ['color', 'background-color', 'font-weight', 'font-style', 'text-decoration']; // Properties inlined onto the wrapper so the pasted block keeps the // editable's typography even if only a descendant was selected. const CLIPBOARD_ROOT_STYLE_PROPS = ['font-family', 'font-size', 'line-height', 'white-space', 'background-color', 'color']; // A small amount of padding + rounded corners gives the pasted snippet // a card-like appearance in rich-text targets without overriding the // background or font that consumers already control via the editable's // own styles. const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;'; /** * Everything {@link createEditableEngine} needs from its host hook. `useEditable` * owns this state and these refs so they survive this module's lazy load; the * engine only reads and mutates them, and they are shared by reference so the * engine's handlers always observe live values. */ /** * The heavy editing runtime bound to a host element. `setup` applies * `contentEditable` and binds the keyboard/paste/caret handlers; `observeAndRestore` * runs the per-render MutationObserver + caret-restore pass. Each returns its cleanup. */ /** * Resolves the editing engine factory. `CodeProvider` supplies one via context * (eager → bundled, resolves instantly; lazy → dynamic `import()`); `useEditable` * also has a built-in fallback so editing works without a provider. */ /** * Builds the editing engine for a host element. This module statically imports * the heavy editing utilities (`useEditableUtils`, `cloneRangeWithInlineStyles`, * `stripLeadingPerLine`) and `react-dom`, so the bundler emits it as a separate * chunk that `useEditable` loads on demand — read-only code blocks never pull it in. */ export const createEditableEngine = ctx => { const { elementRef, state, observerRef, boundsRef, configRef, unblock } = ctx; // MutationObserver is created lazily here (not in the host hook) so code // blocks that never activate editing never allocate one. The host owns the // ref; the engine fills it on first construction. if (observerRef.current === null && typeof MutationObserver !== 'undefined') { observerRef.current = new MutationObserver(batch => { state.queue.push(...batch); }); } const edit = { update(content) { const { current: element } = elementRef; if (element) { const position = getPosition(element); const prevContent = toString(element); position.position += content.length - prevContent.length; state.position = position; state.onChange(content, position); } }, insert(append, deleteOffset) { const { current: element } = elementRef; if (element) { let range = getCurrentRange(); range.deleteContents(); range.collapse(); const position = getPosition(element); const offset = deleteOffset || 0; const start = position.position + (offset < 0 ? offset : 0); const end = position.position + (offset > 0 ? offset : 0); range = makeRange(element, start, end); adjustCursorAtNewlineBoundary(range); range.deleteContents(); if (append) { range.insertNode(document.createTextNode(append)); } const cursorRange = makeRange(element, start + append.length); adjustCursorAtNewlineBoundary(cursorRange); setCurrentRange(cursorRange); } }, move(pos) { const { current: element } = elementRef; if (element) { element.focus(); const position = typeof pos === 'number' ? pos : getOffsetAtLineColumn(element, pos.row, pos.column); const cursorRange = makeRange(element, position); adjustCursorAtNewlineBoundary(cursorRange); setCurrentRange(cursorRange); } }, getState() { const element = elementRef.current; if (!element) { // Pre-mount / unmounted: return an empty snapshot so callers // that subscribe before the ref is attached get a stable shape. return { text: '', position: { position: 0, extent: 0, content: '', line: 0 } }; } return { text: toString(element), position: getPosition(element) }; } }; // Per-render observe + caret-restore + external-swap snapshot. The host hook // calls this from a layout effect on every render once the engine exists. const observeAndRestore = () => { // Only for SSR / server-side logic // typeof navigator check fails on Node.js 21+ which exposes navigator.userAgent; // typeof window is the standard isomorphic SSR guard. if (typeof window === 'undefined') { return undefined; } const config = configRef.current; if (!elementRef.current || config.disabled) { return undefined; } // Detect content swaps that happen outside the keystroke pipeline (e.g. // a host calling `setSource(...)` from a Reset button or React state // change) and snapshot them into the undo stack so the user can Ctrl+Z // back to their prior text. We skip this on the post-flush re-render // (`state.disconnected === true`): in that case `flushChanges` has just // recorded the new content via `trackState`, so re-reading the DOM // would only re-confirm what we already know — wasting an O(N) walk // on every keystroke. We also skip while a user edit is in flight // (`pendingContent !== null`) so we don't race with the imminent // flush. Finally, we only push when there's already a recorded entry // that the new content differs from — the initial-baseline capture // before the very first user edit is left to `trackState`'s keydown // path so we don't double-record (and inadvertently arm its 500ms // dedup timestamp before flushChanges gets a chance to record the // post-edit state). if (!state.disconnected && state.pendingContent === null && state.history.length > 0) { // Detect host-driven content swaps (e.g. a `setSource(...)` from a // Reset button or an external React state change) and snapshot // them into the undo stack so the user can Ctrl+Z back to their // prior text. We compare the live DOM against // `state.lastCommittedContent` — the content of the most recent // `onChange` call. After a normal commit, React's reconciliation // produces a DOM whose `toString()` matches `lastCommittedContent` // exactly, so the comparison is a cheap no-op. After an external // swap they differ and we record the new entry. // // We deliberately do NOT use the MutationObserver record queue as // a gate here: React's own reconciliation between renders fires // records too, and pushing those into `state.queue` would cause // `commit()` to revert React's DOM patches on the next keystroke. // The observer's per-render `disconnect()` (in the cleanup below) // drops those records on the floor by design. const lastCommitted = state.lastCommittedContent; if (lastCommitted !== null) { const currentContent = toString(elementRef.current); if (currentContent !== lastCommitted) { const lastEntry = state.history[state.historyAt]; // Recover edits the 500ms dedup kept out of `history`. Without // this, a user who typed within the dedup window then // triggered an external swap would lose those keystrokes // entirely on undo: history holds only the pre-typing // checkpoint, so Ctrl+Z would jump straight past the user's // most recent state. if (lastEntry && lastCommitted !== lastEntry[1]) { state.historyAt += 1; const at = state.historyAt; state.history[at] = [state.position ?? lastEntry[0], lastCommitted]; state.history.splice(at + 1); if (at > 500) { state.historyAt -= 1; state.history.shift(); } } const lastEntryAfter = state.history[state.historyAt]; state.historyAt += 1; const at = state.historyAt; state.history[at] = [lastEntryAfter ? lastEntryAfter[0] : state.position ?? { position: 0, extent: 0, content: '', line: 0 }, currentContent]; state.history.splice(at + 1); if (at > 500) { state.historyAt -= 1; state.history.shift(); } state.lastCommittedContent = currentContent; } } } state.disconnected = false; observerRef.current?.observe(elementRef.current, observerSettings); // Skip restoring the cursor while a key is held down. The debounced // flushChanges hasn't run yet so state.position is stale; restoring it // here would jump the cursor back on every incidental re-render (e.g. // from an async enhancer setState). edit.insert() already placed the // cursor correctly in the DOM — leave it there until the debounce fires. // // Also skip on the render right after an arrow-key boundary callback // (see `state.skipNextRestore`): the native arrow movement hasn't // applied yet, so `state.position` is the pre-arrow location and // restoring it would visibly snap the caret back upward/downward. if (state.skipNextRestore) { state.skipNextRestore = false; } else if (state.position && state.repeatFlushId === null) { restoreSelection(elementRef.current, state.position); } return () => { // Drain the observer's pending record queue into a single dirty // bit BEFORE disconnecting. `disconnect()` per spec drops the // queue, which would otherwise hide an external DOM swap that // happened between this render's commit and the next render's // snapshot block. We deliberately do NOT push the records into // `state.queue`: React's own reconciliation mutations land here // too, and `commit()` on the next keystroke would revert them, // corrupting the rendered DOM. The boolean is a pure gating // signal — the snapshot block does its own `toString` comparison // against `lastCommittedContent` to decide whether the change was // a real swap or just React reconciling to the committed content. const pending = observerRef.current?.takeRecords(); if (pending && pending.length > 0) { state.domDirty = true; } observerRef.current?.disconnect(); }; }; // Applies contentEditable and binds the keyboard/paste/caret handlers. The // host hook calls this from a layout effect; it re-runs when the element, // `disabled`, or `indentation` change (matching the prior effect deps). const setup = () => { if (typeof window === 'undefined') { return undefined; } const config = configRef.current; if (!elementRef.current || config.disabled) { state.history.length = 0; state.historyAt = -1; return undefined; } const element = elementRef.current; if (!element) { return undefined; } if (state.position) { element.focus(); restoreSelection(element, state.position); } const prevWhiteSpace = element.style.whiteSpace; const prevContentEditable = element.contentEditable; let hasPlaintextSupport = true; try { // Firefox and IE11 do not support plaintext-only mode element.contentEditable = 'plaintext-only'; } catch (_error) { element.contentEditable = 'true'; hasPlaintextSupport = false; } // Defer the `getComputedStyle` read + conditional inline-style // writes into a module-level microtask so all editables on the page // share a single style recalc instead of forcing one per instance. // `styleSetupCancelled` shorts the task out if cleanup runs before // the microtask fires (e.g. an unmount in the same tick as commit). let styleSetupCancelled = false; scheduleEditableStyleTask(() => { if (styleSetupCancelled) { return; } // Only set inline styles when the computed style isn't already // suitable. This lets consumers control these properties via CSS // (e.g. a `pre` selector) without us clobbering their values with // inline styles that win specificity. const computed = element.ownerDocument.defaultView?.getComputedStyle(element); const computedWhiteSpace = computed?.whiteSpace ?? ''; // Any whitespace-preserving value works for an editable surface. // `pre-line` is intentionally excluded because it collapses runs // of spaces, which would corrupt indentation. const whiteSpaceIsPreserving = computedWhiteSpace === 'pre' || computedWhiteSpace === 'pre-wrap' || computedWhiteSpace === 'break-spaces'; if (!whiteSpaceIsPreserving) { element.style.whiteSpace = 'pre-wrap'; } if (config.indentation) { const tabSizeValue = `${config.indentation}`; if (computed?.tabSize !== tabSizeValue) { element.style.setProperty('-moz-tab-size', tabSizeValue); element.style.tabSize = tabSizeValue; } } }); const indentPattern = `${' '.repeat(config.indentation || 0)}`; const indentRe = new RegExp(`^(?:${indentPattern})`); const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`); let trackStateTimestamp; const trackState = (ignoreTimestamp, contentOverride, positionOverride) => { // Require a live selection so getPosition() (which calls getRangeAt(0)) is safe. // Using !state.position would block recording the initial state: state.position is // only set by flushChanges() which runs on keyup — after the first edit. Switching // to rangeCount === 0 lets the very first keydown snapshot the pre-edit content. if (!elementRef.current || (window.getSelection()?.rangeCount ?? 0) === 0) { return null; } // Callers may pass in already-computed (and possibly repaired) content so // we don't re-read a buggy intermediate DOM. flushChanges uses this to // record the repaired post-edit state instead of the merged DOM that // Firefox/observer left behind. const content = contentOverride ?? toString(element); const position = positionOverride ?? getPosition(element); const timestamp = new Date().valueOf(); // Prevent recording new state in list if last one has been new enough const lastEntry = state.history[state.historyAt]; if (!ignoreTimestamp && timestamp - trackStateTimestamp < 500 || lastEntry && lastEntry[1] === content) { trackStateTimestamp = timestamp; return content; } state.historyAt += 1; const at = state.historyAt; state.history[at] = [position, content]; state.history.splice(at + 1); if (at > 500) { state.historyAt -= 1; state.history.shift(); } return content; }; const disconnect = () => { observerRef.current?.disconnect(); state.disconnected = true; }; const flushChanges = (ignoreTimestamp, bypassPreParse, positionFlags) => { const records = observerRef.current?.takeRecords() ?? []; state.queue.push(...records); const position = getPosition(element); // Caller-supplied metadata that the post-edit caret can't carry on its own // (e.g. that a selection delete started at column 0). Rides on the reported // position into `onChange`/history so derived state and undo can use it. if (positionFlags) { Object.assign(position, positionFlags); } if (state.queue.length) { // We DO NOT revert the queued mutations yet — letting them stay in // the live DOM means the user's keystroke remains visible while // `preParse` runs. The mutation queue is held until commit (below) // so when React eventually re-renders the highlighted content, it // first sees its expected previous DOM. const content = repairUnexpectedLineMerge(toString(element), state.pendingContent, position); state.position = position; // Record the REPAIRED content into history before notifying the app. // Reading toString() back from the DOM here would capture the buggy // pre-repair state (e.g. a Firefox line-merge), which is what was // previously polluting the undo stack. trackState(ignoreTimestamp, content, position); // Snapshot the queue length representing mutations that belong to // THIS flush. Anything appended past this index by the time // `commit` runs is a straggler — a newer keystroke whose own // keyup-triggered `flushChanges` will produce a fresher commit. In // that case we must NOT revert the stragglers (or we'd lose the // user's character) and we must NOT call `onChange` with our now // stale `content` (or we'd briefly render the older state on top // of the newer DOM). const queueLengthAtFlush = state.queue.length; // Commit phase: revert the queued mutations and hand control to // React. The revert + React commit are bundled into a single task // via `flushSync` so the browser cannot paint the briefly-reverted // DOM between the two — the user's keystroke stays continuously on // screen, transitioning directly from "raw mutation" to // "highlighted React render". const commit = preParseResult => { // Drain anything pending in the observer first so we have an // accurate count of stragglers (mutations made after this // flush started). The observer stays connected during the // `preParse` await so additional keystrokes ARE captured but // are NOT blocked by the `state.disconnected` guard in // `onKeyDown`. const stragglers = observerRef.current?.takeRecords() ?? []; state.queue.push(...stragglers); if (state.queue.length > queueLengthAtFlush) { // A newer keystroke landed in the DOM after this flush // started. Drop this commit on the floor — the straggler's // own `flushChanges` (already running, or about to run on // its keyup) will produce a fresher commit that reverts the // entire combined mutation set and reports the up-to-date // content. Leaving the observer connected and // `state.disconnected` false lets onKeyDown keep accepting // input in the meantime. return; } disconnect(); while (state.queue.length > 0) { const mutation = state.queue.pop(); if (!mutation) { break; } if (mutation.oldValue !== null) { mutation.target.textContent = mutation.oldValue; } for (let i = mutation.removedNodes.length - 1; i >= 0; i -= 1) { mutation.target.insertBefore(mutation.removedNodes[i], mutation.nextSibling); } for (let i = mutation.addedNodes.length - 1; i >= 0; i -= 1) { if (mutation.addedNodes[i].parentNode) { mutation.target.removeChild(mutation.addedNodes[i]); } } } ReactDOM.flushSync(() => { state.lastCommittedContent = content; if (preParseResult === undefined) { // Preserve the historical (text, position) calling convention // for the sync / bypass path so consumers can distinguish a // preParse-result-less commit from one whose result happened // to be `undefined`. state.onChange(content, position); } else { state.onChange(content, position, preParseResult); } }); }; const { preParse } = boundsRef.current; if (preParse && !bypassPreParse) { // Abort any prior in-flight preParse — only the most recent // keystroke's parse result is worth waiting for. if (state.preParseAbort) { state.preParseAbort.abort(); } const controller = new AbortController(); state.preParseAbort = controller; const { signal } = controller; preParse(content, position, signal).then(result => { if (signal.aborted) { return; } if (state.preParseAbort === controller) { state.preParseAbort = null; } commit(result); }, () => { if (state.preParseAbort === controller) { state.preParseAbort = null; } if (signal.aborted) { // Aborted by a newer keystroke — drop silently. The // queued mutations stay in place until the superseding // flush commits them. return; } // Real parse failure (e.g. unknown grammar, worker error). // Fall back to committing without a preParseResult so the // source still propagates to onChange — matching the // historical sync path's fail-open behavior. Without this, // the DOM would show the user's typed text while controlled // state stayed stale, and the next render would revert it. commit(); }); } else { // Structural / synchronous edit — bypass preParse so the React // state sync happens on the same commit as the DOM change. if (state.preParseAbort) { state.preParseAbort.abort(); state.preParseAbort = null; } commit(); } } state.pendingContent = null; }; // Snap a collapsed caret out of an inter-line gap text node (e.g. the // literal `\n` between `.line` spans) onto the nearest `.line` in // `direction`. Used by both the post-arrow rAF and the pointer // handlers — clicks can land in gap nodes too. When `isVertical`, the // caret lands at `preferredColumn` of the target line (clamped); // otherwise it lands at the start (forward) or end (backward). // Returns `true` when a snap was applied. const snapCaretOutOfGapNode = (direction, isVertical, preferredColumn) => { const { caretSelector } = boundsRef.current; if (caretSelector === undefined) { return false; } const sel = element.ownerDocument.defaultView?.getSelection(); if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) { return false; } const snapRange = sel.getRangeAt(0); if (!element.contains(snapRange.startContainer)) { return false; } const startContainer = snapRange.startContainer; const startElement = asElement(startContainer) ?? startContainer.parentElement; // Caret is already inside a `.line` (or equivalent) — no snap needed. if (startElement?.closest(caretSelector)) { return false; } const lineEls = Array.from(element.querySelectorAll(caretSelector)); if (lineEls.length === 0) { return false; } // Use document position to pick the right neighbour. let target = null; if (direction === 'forward') { for (let i = 0; i < lineEls.length; i += 1) { const r = element.ownerDocument.createRange(); r.selectNode(lineEls[i]); // cmp < 0 means the caret is before this line. if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) { target = lineEls[i]; break; } } // No line ahead — caret has landed past the last line. Snap back // to the last line so the caret stays inside an editable row. if (!target) { target = lineEls[lineEls.length - 1]; } } else { for (let i = lineEls.length - 1; i >= 0; i -= 1) { const r = element.ownerDocument.createRange(); r.selectNode(lineEls[i]); // cmp > 0 means the caret is after this line. if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) { target = lineEls[i]; break; } } // No line behind — caret has landed before the first line. if (!target) { target = lineEls[0]; } } if (!target) { return false; } const newRange = element.ownerDocument.createRange(); if (isVertical) { // Walk the target line's text nodes to find the offset that // matches `preferredColumn`, clamping to the line length. const targetText = target.textContent ?? ''; const targetColumn = Math.min(preferredColumn, targetText.length); let remaining = targetColumn; const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT); let placed = false; let node = walker.nextNode(); while (node) { const len = node.textContent?.length ?? 0; if (remaining <= len) { newRange.setStart(node, remaining); newRange.collapse(true); placed = true; break; } remaining -= len; node = walker.nextNode(); } if (!placed) { newRange.selectNodeContents(target); newRange.collapse(false); } } else if (direction === 'forward') { newRange.selectNodeContents(target); newRange.collapse(true); } else { newRange.selectNodeContents(target); newRange.collapse(false); } sel.removeAllRanges(); sel.addRange(newRange); return true; }; // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`) // when the user clicks there. The arrow-key handler already prevents // landing inside the gutter via keyboard navigation; this covers // pointer-driven clicks. Range selections are left alone — clamping the // anchor of a drag would feel surprising mid-gesture. const snapCaretOutOfGutter = () => { const { minColumn } = boundsRef.current; if (minColumn === undefined || minColumn <= 0) { return; } const sel = element.ownerDocument.defaultView?.getSelection(); if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) { return; } const range = sel.getRangeAt(0); if (!element.contains(range.startContainer)) { return; } const position = getPosition(element); if (position.content.length >= minColumn) { return; } // Only snap when the gutter is actually whitespace — otherwise the // line is shorter than `minColumn` and there's nowhere to snap to. // `getLineInfo` walks just enough text nodes to read the current // line; avoids materializing the full document text on every click. const lineText = getLineInfo(element, position.line).currentLine; if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) { return; } edit.move({ row: position.line, column: minColumn }); }; // The most recent non-empty `caretSelector`. The host may briefly drop it // (e.g. `shouldHighlight` flips false while the post-edit re-highlight is in // flight), but the rendered `.line` structure persists across that window, // so we latch the selector to keep framed-line handling stable mid-edit. let latchedCaretSelector = boundsRef.current.caretSelector; // True when this is a framed (`caretSelector`) editor — i.e. the content is // rendered as `.line` spans inside `.frame` wrappers separated by inter-line // gap text nodes. Native plaintext-only typing at a `.line`/gap boundary // lands the character in the `.frame` wrapper instead, flattening the line // spans and splitting input across rows (which then strands the caret at the // line start on Backspace). Routing every printable key through the // controlled `edit.insert` keeps the character inside its line span. We key // off the *latched* selector (not the live caret position) because the caret // can momentarily sit in a gap node mid-edit and the host briefly drops // `caretSelector` while a post-edit re-highlight is in flight. const framedEditorActive = () => { const configured = boundsRef.current.caretSelector; if (configured !== undefined) { latchedCaretSelector = configured; } return latchedCaretSelector !== undefined; }; const onKeyDown = event => { if (event.defaultPrevented || event.target !== element) { return; } if (state.disconnected) { // React Quirk: between flushChanges() (which calls disconnect() and // rewinds the DOM back to the pre-edit content) and React's commit // (which re-observes via useLayoutEffect and restores state.position), // an event can fire that we'd otherwise mishandle. // // For NAVIGATION keys (arrows) the DOM revert is irrelevant — the // browser only needs a valid caret position to compute the next // selection — so resync inline (restore caret + re-observe) and let // the event proceed. Otherwise the keystroke would be eaten and the // user would lose, for example, an ArrowUp step after Enter inside // a focus frame. We deliberately do NOT include Home/End/PageUp/ // PageDown here: they would also need to compensate for the pending // rerender (matching the arrow-key skip-next-restore handling) and // currently lack that coverage, so keep them on the safe path. // // For EDITING keys (printable text, Enter, Tab, Backspace, Delete, // …) we must NOT fall through: the live DOM is the reverted // pre-edit snapshot, so applying a second edit on top would target // the wrong text and corrupt content. Keep the original block-and- // unblock behavior for those keys — React will commit the queued // onChange momentarily and the user can re-issue the keystroke. const isArrowKey = event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowDown'; if (!isArrowKey) { event.preventDefault(); unblock([]); return; } if (state.position && state.repeatFlushId === null) { restoreSelection(element, state.position); } observerRef.current?.observe(element, observerSettings); state.disconnected = false; // The `unblock([])` below schedules a React rerender. If that // rerender's restore effect runs before the native arrow movement // has updated `state.position` (which happens asynchronously via // `selectionchange`), the restore would snap the caret back to the // stale pre-arrow position. In practice `selectionchange` usually // fires first so the restore is a no-op, but arming the skip flag // makes the fast path race-free regardless of scheduling. The // boundary-movement branches arm the same flag for the same reason. state.skipNextRestore = true; unblock([]); // Fall through and let this arrow event be handled normally // with the restored caret position. } if (isUndoRedoKey(event)) { event.preventDefault(); let history; // The state we are leaving — its position is the POST-edit caret of the // edit being undone, which the host needs as the reversal pivot (it can // differ from the destination's PRE-edit caret after a selection edit). let leavingPosition; if (!event.shiftKey) { const leavingAt = state.historyAt; state.historyAt -= 1; const at = state.historyAt; history = state.history[at]; if (!history) { state.historyAt = 0; } else { leavingPosition = state.history[leavingAt]?.[0]; } } else { state.historyAt += 1; const at = state.historyAt; history = state.history[at]; if (!history) { state.historyAt = state.history.length - 1; } } if (history) { disconnect(); state.position = history[0]; state.lastCommittedContent = history[1]; // Tag the reported position with the navigation direction so the host // can reverse the edit's derived state (e.g. the comment/highlight map) // relative to this PRE-edit caret instead of assuming a forward-edit // (post-edit) caret. On undo, also pass the reversed edit's anchor line // (the leaving state's caret) so the reversal pivots on the same line // the forward edit did — they diverge after a selection edit (e.g. // Select All). A fresh object keeps the stored history entry clean for // re-navigation. state.onChange(history[1], { ...history[0], history: event.shiftKey ? 'redo' : 'undo', ...(leavingPosition ? { historyPivotLine: leavingPosition.line, // Carry the reversed edit's column-0 flag so the reversal drops // its anchor by the same line the forward edit did, keeping the // collapseMap keys aligned across delete↔undo. deletedFromLineStart: leavingPosition.deletedFromLineStart } : {}) }); } return; } // Only capture the pre-edit snapshot when no edit is currently pending // (i.e. the previous keystroke has already been flushed on keyup). // Overwriting pendingContent on a rapid second keydown — whether the // same key repeating OR a different key pressed before the first // keyup — would lose the baseline that repairUnexpectedLineMerge // needs to detect Firefox's line-merge quirk. The DOM may already // contain a merged state when the second keydown fires; treating that // as "previous" content makes the line-loss invisible. if (state.pendingContent === null) { state.pendingContent = trackState() ?? toString(element); } if (event.key === 'Enter') { event.preventDefault(); // Firefox Quirk: Since plaintext-only is unsupported we must // ensure that only newline characters are inserted const position = getPosition(element); // We also get the current line and preserve indentation for the next // line that's created const match = /\S/g.exec(position.content); const index = match ? match.index : position.content.length; const text = `\n${position.content.slice(0, index)}`; edit.insert(text); // Pressing Enter on the last visible row pushes the new line past the // collapsed window's fold, where there is no rendered `.line` to host // the caret (it would strand in the padding filler). Mirror the // arrow-key boundary handling and ask the host to expand. Cheap: a // single bounds read plus the `getPosition` we already need for the // post-expand caret restore. const { maxRow, onBoundary } = boundsRef.current; if (maxRow !== undefined && onBoundary && position.line >= maxRow) { state.position = getPosition(element); state.skipNextRestore = true; onBoundary(); } else if (!event.repeat) { // Reconcile synchronously (revert the raw newline, re-render React's // frame structure in one `flushSync`) so an Enter that MOVES an // emphasis frame — e.g. re-splitting a line whose earlier Backspace // merge had scrolled the collapsed window — repositions the window in // the same task as the native insert. Without this the live DOM keeps // the pre-reconcile window position until the keyup flush, a visible // flash. Mirrors the synchronous Backspace-merge path; the keyup flush // then no-ops (content unchanged → `trackState` dedups). Held Enter // (`event.repeat`) keeps the debounced keyup flush so the highlight // re-runs once on release instead of once per repeat. flushChanges(true, true); return; } } else if (!event.isComposing && isPlaintextInputKey(event) && (!hasPlaintextSupport || framedEditorActive())) { // Firefox Quirk: native typing in contentEditable="true" can insert // directly into the frame wrapper before the current line span. // // Chromium/WebKit (plaintext-only) Quirk: native typing at the END of a // framed `.line` (the boundary with the inter-line gap text node) // likewise lands the character in the `.frame` wrapper, flattening the // line spans and splitting subsequent input onto the next row — which // then strands the caret at the line start on the next Backspace. // // Route plain text input through the controlled insert path in both // cases so the character lands inside the current line span. event.preventDefault(); edit.insert(event.key); } else if ((!hasPlaintextSupport || config.indentation) && event.key === 'Backspace' && !event.metaKey && !event.ctrlKey && !event.altKey) { // Firefox Quirk: Since plaintext-only is unsupported we must // ensure that only a single character is deleted. // // Modifier guard: Ctrl/Meta/Alt+Backspace request word- or // line-granular deletion. Mirror the forward-`Delete` branch below // and let those modified presses fall through to the browser's // native `deleteWord*`/`deleteSoftLine*` so a held modifier keeps its // OS deletion semantics instead of being downgraded to a single char. event.preventDefault(); const beforePosition = getPosition(element); const range = getCurrentRange(); if (!range.collapsed) { // Whether the deletion removed WHOLE lines from the first line down. True // only when the selection BOTH started at column 0 (no content before it) // AND ended at a line boundary (its text ends with a newline) — then the // first line is gone and the post-delete caret lands on the line that // shifted up from below, so the comment-map anchor sits one line higher // (see `deletedFromLineStart`). A selection that ends MID-line instead // collapses the spanned lines INTO the first line, which survives (emptied) // under the caret — no shift-up — so the flag must stay false, or a marker // on that surviving line is dragged one line too high. const deletedFromLineStart = beforePosition.content.length === 0 && range.toString().endsWith('\n'); edit.insert('', 0); // A multi-line selection delete can natively remove whole `.frame` // wrapper elements (e.g. selecting exactly one emphasis frame). That // detaches nodes React still holds, so its next reconcile throws // `removeChild`/`NotFoundError` and unmounts the whole editor. Reconcile // synchronously (revert the raw mutation, re-render from the new source // in one `flushSync`) so React owns the structural change consistently. flushChanges(true, true, { deletedFromLineStart }); return; } // Collapsed caret (the non-collapsed range case returned above). const { minColumn } = boundsRef.current; // When the caret sits at `minColumn` on a blank (whitespace-only) // line inside a clipped indent gutter, a single-character Backspace // would step into `[0, minColumn)` — visually invisible to the user // since that range is hidden by the host. Clearing one indent unit // at a time would leave the caret stranded in that hidden gutter. // Instead, clear the WHOLE clipped indent in one Backspace so the // line becomes truly empty and the caret lands at its visible // column 0 — keeping the caret on the same line rather than // collapsing the line and jumping it up to the previous one. // // Walk only enough text nodes to read the current line — we // don't need the rest of the document on every Backspace. const clearsClippedIndent = minColumn !== undefined && minColumn > 0 && beforePosition.line > 0 && beforePosition.content.length === minColumn && /^\s*$/.test(beforePosition.content); let handled = false; if (clearsClippedIndent && minColumn !== undefined) { // The redundant `minColumn !== undefined` check pins TS's // narrowing across the boundary so we can use `minColumn` // as a number directly without an assertion. const fullLine = getLineInfo(element, beforePosition.line).currentLine; if (fullLine.length === minColumn && /^\s*$/.test(fullLine)) { edit.insert('', -minColumn); handled = true; } } if (!handled) { const match = blanklineRe.exec(beforePosition.content); edit.insert('', match ? -match[1].length : -1); } // If the deletion left the current line empty, OR merged this line up // into the previous one (a Backspace at column 0 deletes the preceding // newline), the browser leaves a transient zero-height/collapsed // `.line` span in the DOM that only disappears once the change commits // and React re-renders. Left to the keyup flush (or an async // re-highlight) the line blinks out and back — the visible flash when // "removing the last part of a line full of spaces" or backspacing a // line up into the one above. Reconcile synchronously (bypassing // preParse) so the final structure is in place before the next paint. const afterDelete = getPosition(element); const lineEmptied = getLineInfo(element, afterDelete.line).currentLine.length === 0; const lineMerged = afterDelete.line < beforePosition.line; if (lineEmptied || lineMerged) { flushChanges(true, true); return; } } else if ((!hasPlaintextSupport || framedEditorActive()) && event.key === 'Delete' && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { // Forward delete, mirroring the Backspace handling. Native plaintext-only // forward-delete is unreliable in framed editors: at a `.line`/gap // boundary it can no-op instead of merging the next line, and when it // empties a line it leaves a zero-height empty `.line` that flashes // before the async re-highlight commits. Route it through the controlled // `edit.insert` so the deletion is predictable, then reconcile // synchronously when the line empties (same flash fix as Backspace). event.preventDefault(); const range = getCurrentRange(); if (!range.collapsed) { // See the Backspace branch above: deletedFromLineStart holds only when the // selection removed whole lines (started at column 0 AND ended at a line // boundary). A mid-line end collapses the lines in place, leaving the first // line emptied under the caret — no sh