From 34bf880a68dffeb32ebf3df685f432550b5d466f Mon Sep 17 00:00:00 2001
From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:08:58 +0530
Subject: [PATCH 1/6] [docs-infra] Add a textarea code editor
---
.../src/useCode/CodeEditor.test.tsx | 195 +++++++++++++
.../docs-infra/src/useCode/CodeEditor.tsx | 263 ++++++++++++++++++
.../docs-infra/src/useCode/CodeEditorLazy.tsx | 39 +++
.../docs-infra/src/useCode/codeEditorCache.ts | 57 ++++
.../src/useCode/codeEditorEdits.test.ts | 81 ++++++
.../docs-infra/src/useCode/codeEditorEdits.ts | 116 ++++++++
.../docs-infra/src/useCode/editingTypes.ts | 50 ++++
packages/docs-infra/src/useCode/index.ts | 2 +
8 files changed, 803 insertions(+)
create mode 100644 packages/docs-infra/src/useCode/CodeEditor.test.tsx
create mode 100644 packages/docs-infra/src/useCode/CodeEditor.tsx
create mode 100644 packages/docs-infra/src/useCode/CodeEditorLazy.tsx
create mode 100644 packages/docs-infra/src/useCode/codeEditorCache.ts
create mode 100644 packages/docs-infra/src/useCode/codeEditorEdits.test.ts
create mode 100644 packages/docs-infra/src/useCode/codeEditorEdits.ts
create mode 100644 packages/docs-infra/src/useCode/editingTypes.ts
diff --git a/packages/docs-infra/src/useCode/CodeEditor.test.tsx b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
new file mode 100644
index 000000000..b4e7cd032
--- /dev/null
+++ b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * @vitest-environment jsdom
+ */
+import * as React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { HastRoot } from '../CodeHighlighter/types';
+import { CodeContext } from '../CodeProvider/CodeContext';
+import { CodeEditor } from './CodeEditor';
+
+function textarea() {
+ return screen.getByRole('textbox') as HTMLTextAreaElement;
+}
+
+/** Types into the textarea the way the browser does — value then `input`. */
+function type(element: HTMLTextAreaElement, value: string, caret = value.length) {
+ element.value = value;
+ element.setSelectionRange(caret, caret);
+ fireEvent.input(element);
+}
+
+describe('CodeEditor', () => {
+ it('seeds the textarea from the painted source', () => {
+ render(`. The textarea
+ * owns the text, so selection, undo/redo, IME, and spellcheck stay native; the
+ * `
` beneath it keeps painting, frames and all.
+ *
+ * Nothing is highlighted here. An edit goes out through `setSource`, the host
+ * re-parses, and the `
` re-renders from the new tree — which is what keeps
+ * emphasis frames, collapse placeholders, and the intersection-driven frame
+ * hydration working while editing.
+ *
+ * Indent and outdent go through `document.execCommand('insertText')` rather than
+ * a direct value write, which is what keeps them on the browser's native undo
+ * stack. The `inputType` vocabulary used to classify edits follows the approach
+ * in Pierre's editor (https://github.com/pierrecomputer/pierre).
+ */
+export interface CodeEditorProps {
+ /** Complete source, matching the text painted by the `` underneath. */
+ source: string;
+ /** Canonical file name reported back through `setSource`. */
+ fileName?: string;
+ language?: string;
+ /** Spaces inserted by Tab. */
+ tabSize?: number;
+ setSource: SetSource;
+ /** Fired on first focus, so the host can warm the live runtime. */
+ onActivate?: () => void;
+ /** Fired on Escape, so the host can move focus out. */
+ onExit?: () => void;
+ onReady?: (textarea: HTMLTextAreaElement | null) => void;
+}
+
+function getCaretLine(source: string, position: number): { content: string; line: number } {
+ let line = 0;
+ let lineStart = 0;
+ for (let index = 0; index < position; index += 1) {
+ if (source[index] === '\n') {
+ line += 1;
+ lineStart = index + 1;
+ }
+ }
+ return { content: source.slice(lineStart, position), line };
+}
+
+/**
+ * Replaces a range through `execCommand` so the edit lands on the browser's
+ * native undo stack. Falls back to a direct value write where `execCommand` is
+ * unavailable, which loses undo for that one edit rather than dropping it.
+ */
+function replaceRange(
+ textarea: HTMLTextAreaElement,
+ start: number,
+ end: number,
+ text: string,
+): void {
+ textarea.setSelectionRange(start, end);
+ if (document.execCommand?.('insertText', false, text)) {
+ return;
+ }
+ textarea.value = `${textarea.value.slice(0, start)}${text}${textarea.value.slice(end)}`;
+}
+
+export function CodeEditor({
+ source,
+ fileName,
+ language,
+ tabSize = 2,
+ setSource,
+ onActivate,
+ onExit,
+ onReady,
+}: CodeEditorProps) {
+ const { parseSourceAsync } = useCodeContext();
+ const textareaRef = React.useRef` element and copy its resolved
+ // font metrics. Inheriting from the `` is not enough: `
` and its
+ // `.line` spans can carry their own font-size and line-height, and even a
+ // fraction of a pixel per line compounds into visible drift further down the
+ // block. Measured off `` so the textarea's own size cannot feed back in.
+ React.useLayoutEffect(() => {
+ const textarea = textareaRef.current;
+ const pre = textarea?.parentElement;
+ const code = pre?.querySelector('code');
+ if (!textarea || !pre || !code) {
+ return undefined;
+ }
+
+ const sync = () => {
+ const styles = window.getComputedStyle(code);
+ textarea.style.font = styles.font;
+ textarea.style.fontFamily = styles.fontFamily;
+ textarea.style.fontSize = styles.fontSize;
+ textarea.style.fontWeight = styles.fontWeight;
+ textarea.style.lineHeight = styles.lineHeight;
+ textarea.style.letterSpacing = styles.letterSpacing;
+ textarea.style.tabSize = styles.tabSize;
+ // The text is transparent, so `currentcolor` would make the caret
+ // invisible too. Paint it in the colour the code itself renders in.
+ textarea.style.caretColor = styles.color;
+
+ // Each line is wrapped in a frame span that carries the horizontal
+ // padding, so the glyphs start inboard of ``'s own box. Mirror that
+ // padding or every line sits a fixed offset left of the painted text.
+ const frame = code.querySelector('.frame, .line');
+ const frameStyles = frame ? window.getComputedStyle(frame) : null;
+ textarea.style.paddingLeft = frameStyles?.paddingLeft ?? '0px';
+ textarea.style.paddingRight = frameStyles?.paddingRight ?? '0px';
+
+ textarea.style.top = `${code.offsetTop - pre.clientTop}px`;
+ textarea.style.left = `${code.offsetLeft - pre.clientLeft}px`;
+ textarea.style.width = `${code.scrollWidth}px`;
+ textarea.style.height = `${code.scrollHeight}px`;
+ };
+
+ sync();
+ const observer = new ResizeObserver(sync);
+ observer.observe(code);
+ observer.observe(pre);
+ return () => observer.disconnect();
+ }, [source]);
+
+ // Adopt source that did not originate here — a reset, a transform swap, or a
+ // file switch. An echo of our own last edit is ignored so the caret survives.
+ React.useEffect(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ return;
+ }
+ const fileChanged = previousFileRef.current !== fileName;
+ previousFileRef.current = fileName;
+ if (!fileChanged && source === lastEmittedRef.current) {
+ return;
+ }
+ if (textarea.value !== source) {
+ textarea.value = source;
+ }
+ }, [source, fileName]);
+
+ const emit = React.useCallback(
+ (nextValue: string, selectionStart: number, selectionEnd: number) => {
+ lastEmittedRef.current = nextValue;
+ const position: Position = {
+ position: selectionStart,
+ extent: selectionEnd - selectionStart,
+ ...getCaretLine(nextValue, selectionStart),
+ };
+
+ // Hand the host a pre-parsed tree when a worker parser is available, so
+ // the re-highlight stays off the main thread during typing.
+ if (parseSourceAsync && fileName) {
+ const controller = new AbortController();
+ parseSourceAsync(nextValue, fileName, language, controller.signal).then(
+ (hast: HastRoot) => setSource(nextValue, fileName, position, hast),
+ () => setSource(nextValue, fileName, position),
+ );
+ return;
+ }
+ setSource(nextValue, fileName, position);
+ },
+ [setSource, fileName, language, parseSourceAsync],
+ );
+
+ const handleInput = React.useCallback(
+ (event: React.FormEvent
{
- setCaptured(nextSource);
- setSource(nextSource);
- }}
- shouldHighlight
- >
- {highlightedSource}
-
-
+ {createHighlightedSource(INITIAL_SOURCE)}
+ ,
+ );
+
+ expect(screen.queryByRole('textbox')).toBeNull();
});
});
diff --git a/packages/docs-infra/src/useCode/Pre.test.tsx b/packages/docs-infra/src/useCode/Pre.test.tsx
index 2e087931d..13165ac54 100644
--- a/packages/docs-infra/src/useCode/Pre.test.tsx
+++ b/packages/docs-infra/src/useCode/Pre.test.tsx
@@ -17,14 +17,14 @@ import * as decodeHastSourceModule from '../pipeline/loadIsomorphicCodeVariant/d
import { createParseSource } from '../pipeline/parseSource';
import { enhanceCodeEmphasis } from '../pipeline/enhanceCodeEmphasis';
import { Pre } from './Pre';
-import { preloadEditableEngine } from './useEditable';
+import { preloadCodeEditor } from './codeEditorCache';
-// ``'s editable path now loads its editing engine on demand and only
-// applies `contentEditable` once it resolves. Warm that load once so editable
-// renders below attach synchronously (within `act`), mirroring the warmed
-// module cache a real page reaches after its first editable block.
+// ``'s editable path loads the editor on demand and only mounts the
+// textarea once it resolves. Warm that load once so editable renders below
+// mount synchronously (within `act`), mirroring the warmed module cache a real
+// page reaches after its first editable block.
beforeAll(async () => {
- await preloadEditableEngine();
+ await preloadCodeEditor();
});
const FILE_NAME = 'CheckboxBasic.tsx';
@@ -161,59 +161,6 @@ function createHighlightedSource(source: string): HastRoot {
return enhanceCodeEmphasis(root, HIGHLIGHT_COMMENTS, FILE_NAME) as HastRoot;
}
-function placeCaret(element: HTMLElement, offset: number) {
- element.focus();
- const selection = window.getSelection()!;
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let current = 0;
- let node = walker.nextNode();
-
- while (node) {
- const length = node.textContent?.length ?? 0;
- if (current + length >= offset) {
- const range = document.createRange();
- range.setStart(node, offset - current);
- range.collapse(true);
- selection.removeAllRanges();
- selection.addRange(range);
- return;
- }
-
- current += length;
- node = walker.nextNode();
- }
-}
-
-function insertPlaintextCharacter(element: HTMLElement, key: string) {
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key,
- code: `Key${key.toUpperCase()}`,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- const selection = window.getSelection()!;
- const range = selection.getRangeAt(0);
- range.deleteContents();
- const textNode = document.createTextNode(key);
- range.insertNode(textNode);
- range.setStartAfter(textNode);
- range.collapse(true);
- selection.removeAllRanges();
- selection.addRange(range);
-
- element.dispatchEvent(
- new KeyboardEvent('keyup', {
- key,
- code: `Key${key.toUpperCase()}`,
- bubbles: true,
- cancelable: true,
- }),
- );
-}
-
const getFrameTypes = (container: HTMLElement) =>
Array.from(container.querySelectorAll('span.frame'), (frame) =>
frame.getAttribute('data-frame-type'),
@@ -250,37 +197,6 @@ describe('Pre', () => {
resizeObserverInstances = null;
});
- it('keeps the line and following itself). - expect(resizeObserverInstances).toHaveLength(1); - expect(resizeObserverInstances![0].observed).toEqual([pre]); + // `` installs one RO on the `` itself. The editor installs its own + // to keep the textarea sized to the painted ``, so select by target + // rather than assuming a single instance. + const preObservers = resizeObserverInstances!.filter( + (instance) => instance.observed.length === 1 && instance.observed[0] === pre, + ); + expect(preObservers).toHaveLength(1); // Reset counters so we can assert *re-observe* behavior in isolation. observeCalls!.length = 0; @@ -407,10 +327,7 @@ describe('Pre', () => { // Trigger the RO callback as a real browser would after a layout // change (e.g. CSS-driven collapse animation). - resizeObserverInstances![0].callback( - [] as unknown as ResizeObserverEntry[], - {} as ResizeObserver, - ); + preObservers[0].callback([] as unknown as ResizeObserverEntry[], {} as ResizeObserver); // Each tracked frame must be unobserved+re-observed so the IO // re-evaluates its clipped/unclipped state without a synchronous diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx index 1f84ae032..b3139acda 100644 --- a/packages/docs-infra/src/useCode/Pre.tsx +++ b/packages/docs-infra/src/useCode/Pre.tsx @@ -2,14 +2,13 @@ import * as React from 'react'; import type { ElementContent, RootContent } from 'hast'; -import { useEditable } from './useEditable'; -import type { Position } from './useEditable'; import type { SetSource } from './useSourceEditing'; import type { HastRoot, VariantSource } from '../CodeHighlighter/types'; import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; import { fallbackToHast, fallbackIsHighlighted } from '../CodeHighlighter/fallbackFormat'; import { useCodeContext } from '../CodeProvider/CodeContext'; -import { hastToJsx, frameFallbackFromSpans } from '../pipeline/hastUtils'; +import { hastToJsx, frameFallbackFromSpans, getHastTextContent } from '../pipeline/hastUtils'; +import { CodeEditorLazy } from './CodeEditorLazy'; import { stripHighlightingSpans } from '../pipeline/hastUtils/stripHighlightingSpans'; import { decodeHastSource } from '../pipeline/loadIsomorphicCodeVariant/decodeHastSource'; import { @@ -510,44 +509,23 @@ export function Pre({ const preRef = React.useRef(null); - // useEditable activates its engine in an effect gated on `disabled`, reading - // `preRef.current` at that point. On first render the ref is still null (the - // callback ref runs later), so we keep the block `disabled` for one - // synchronous re-render and flip `editableReady` true in a layout effect — - // by the time `disabled` goes false, `preRef.current` is populated and the - // engine attaches to a real node, avoiding a contentEditable flash / lost - // cursor on first paint. - const [editableReady, setEditableReady] = React.useState(false); - React.useLayoutEffect(() => { - // Deliberate two-pass mount gate: defer engine activation until the - // `bindPre` callback ref has committed (see 527-533). Flipping this true - // in a layout effect is the documented trigger for the no-flash / - // cursor-retention behavior and isn't derivable during render (the ref is - // intentionally null in render 1). - // eslint-disable-next-line react-hooks/set-state-in-effect - setEditableReady(true); - }, []); + const { codeEditorLoader } = useCodeContext(); - const onEditableChange = React.useCallback( - (text: string, position: Position, preParsed?: HastRoot) => { - setSource?.(text, fileName, position, preParsed); - }, - [setSource, fileName], - ); + const isEditable = Boolean(setSource && editable); - // Worker-backed async parser exposed by `CodeProvider`. When present we - // hand it to `useEditable` as `preParse` so highlighting moves off the - // main thread during live typing. The resolved HAST is forwarded into - // `setSource` (4th arg) where the host can stash it in a per-file cache - // so the synchronous `parseControlledCode` pass can reuse it. - const { parseSourceAsync, editingEngineLoader } = useCodeContext(); - const preParse = React.useMemo(() => { - if (!setSource || !parseSourceAsync || !fileName) { - return undefined; + // `'interaction'` keeps the editor chunk off the wire until the reader + // actually engages with the block; `'eager'` requests it as soon as the block + // is editable. + const [editorRequested, setEditorRequested] = React.useState( + () => isEditable && editActivation !== 'interaction', + ); + React.useEffect(() => { + if (isEditable && editActivation !== 'interaction') { + // eslint-disable-next-line react-hooks/set-state-in-effect -- follows a prop change, not derivable during render + setEditorRequested(true); } - return (text: string, _position: Position, signal: AbortSignal) => - parseSourceAsync(text, fileName, language, signal); - }, [setSource, parseSourceAsync, fileName, language]); + }, [isEditable, editActivation]); + const requestEditor = React.useCallback(() => setEditorRequested(true), []); const [visibleFrames, setVisibleFrames] = React.useState<{ [key: number]: boolean }>(() => getInitialVisibleFrames(hast, collapseToEmpty), @@ -609,26 +587,40 @@ export function Pre({ [hast, expanded, collapseToEmpty], ); - useEditable(preRef, onEditableChange, { - indentation, - disabled: !setSource || !editableReady || !editable, - minColumn: collapsedBounds?.minColumn, - minRow: collapsedBounds?.minRow, - maxRow: collapsedBounds?.maxRow, - onBoundary: collapsedBounds && expand ? expand : undefined, - // The HAST emitted for highlighted code separates `.line` spans with - // whitespace text nodes (newlines) that are direct children of `.frame`. - // Without this, clicks or arrow navigation could land the caret in - // those gap nodes \u2014 visually invisible (collapsed via line-height: 0) - // but still real text positions in contentEditable. `.line` matches - // every selectable row. Only set when the highlighter has actually - // produced `.line` elements. - caretSelector: shouldHighlight ? '.line' : undefined, - preParse, - engineLoader: editingEngineLoader, - activation: editActivation, - onActivate, - }); + // The editor edits complete source as plain text, so a collapsed block would + // otherwise show more in the editor than the ` ` showed. Expanding on + // activation keeps the two in agreement. Editing a collapsed region in place + // needs a source projection, which lands with the projection work. + const handleEditorActivate = React.useCallback(() => { + if (collapsedBounds && expand) { + expand(); + } + onActivate?.(); + }, [collapsedBounds, expand, onActivate]); + + const textareaRef = React.useRef(null); + // Set when Enter requested the editor before its chunk had loaded, so focus + // lands as soon as the textarea exists. + const focusOnReadyRef = React.useRef(false); + const handleEditorReady = React.useCallback((textarea: HTMLTextAreaElement | null) => { + textareaRef.current = textarea; + if (textarea && focusOnReadyRef.current) { + focusOnReadyRef.current = false; + textarea.focus(); + } + }, []); + + // The editor edits plain text, so it needs the decoded source rather than the + // rendered tree. + const editorSource = React.useMemo(() => { + if (!isEditable) { + return ''; + } + if (typeof children === 'string') { + return children; + } + return hast ? getHastTextContent(hast) : ''; + }, [isEditable, children, hast]); const observer = React.useRef (null); const observedFrames = React.useRef >(new Set()); @@ -1102,8 +1094,6 @@ export function Pre({ // regardless of the precomputed value. const sourceFocusedLines = collapseToEmpty ? 0 : rawFocusedLines; - const isEditable = Boolean(setSource) && editable; - // Focus-trap state for editable code blocks. When the user tabs into the // wrapper (keyboard-only, gated by `:focus-visible`), an overlay prompts // them to press Enter before contentEditable Tab-indentation kicks in. @@ -1177,33 +1167,37 @@ export function Pre({ [setPromptVisible], ); - const handleWrapperKeyDown = React.useCallback((event: React.KeyboardEvent ) => { - if (event.target !== event.currentTarget) { - return; - } - if (event.key === 'Enter') { - event.preventDefault(); - preRef.current?.focus(); - } - }, []); - - const handlePreKeyDown = React.useCallback( - (event: React.KeyboardEvent ) => { - if (event.key === 'Escape') { + const handleWrapperKeyDown = React.useCallback( + (event: React.KeyboardEvent ) => { + if (event.target !== event.currentTarget) { + return; + } + if (event.key === 'Enter') { event.preventDefault(); - // Show the prompt explicitly: programmatic `.focus()` doesn't - // reliably trigger `:focus-visible` across browsers (Chrome in - // particular often treats it as non-visible focus), so the - // `onFocus` branch would no-op here. Since we know this came from - // a keyboard Escape, force the overlay back on. - setPromptVisible(true); - // Returning focus to the wrapper restores the page's Tab order. - wrapperRef.current?.focus(); + if (textareaRef.current) { + textareaRef.current.focus(); + return; + } + // An `'interaction'` block has no editor yet: request it, and focus the + // textarea as soon as it mounts. + focusOnReadyRef.current = true; + requestEditor(); } }, - [setPromptVisible], + [requestEditor], ); + const handleEditorExit = React.useCallback(() => { + // Show the prompt explicitly: programmatic `.focus()` doesn't reliably + // trigger `:focus-visible` across browsers (Chrome in particular often + // treats it as non-visible focus), so the `onFocus` branch would no-op + // here. Since we know this came from a keyboard Escape, force the overlay + // back on. + setPromptVisible(true); + // Returning focus to the wrapper restores the page's Tab order. + wrapperRef.current?.focus(); + }, [setPromptVisible]); + // A plain-string source hasn't been highlighted yet (deferred mode). Render it // FRAMED — the compact `fallback` (the loader's windowed plain-text frames) when one // travelled with it, otherwise a single-frame wrap — so the ` ` is never bare @@ -1247,16 +1241,14 @@ export function Pre({ }, [children, collapseToEmpty, fallback]); const preElement = ( - // Theis made interactive by contentEditable (set imperatively by - // useEditable). jsx-a11y can't see that, so disable its rule here. - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions); @@ -1276,7 +1281,7 @@ export function Pre({ return ( // Intentional focus trap: the wrapper is a keyboard-only stop in the // tab order so we can prompt the user ("Press Enter to start editing") - // before contentEditable's Tab-indents-instead-of-moving-focus behavior + // before the textarea's Tab-indents-instead-of-moving-focus behavior // takes over. role="group" + aria-label give it an accessible name. /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */{hast ? frames : framedFallback}+ {editorRequested ? ( ++ ) : null} {/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} + {/* The `") — first line of frame 2 - // Lines 1-8 occupy chars: count up to the start of "` keeps painting — frames, collapse placeholders, and + intersection-driven hydration all still apply. The editor is only a + transparent textarea stacked on top of it, rendered inside. */} {preElement} {/* The overlay stays mounted so consumer styles can animate it in/out based on the wrapper's `data-editable-prompt` attribute. The diff --git a/packages/docs-infra/src/useCode/SourceEditingEngine.ts b/packages/docs-infra/src/useCode/SourceEditingEngine.ts index adc25201f..495513101 100644 --- a/packages/docs-infra/src/useCode/SourceEditingEngine.ts +++ b/packages/docs-infra/src/useCode/SourceEditingEngine.ts @@ -5,7 +5,7 @@ // becomes editable and applies it synchronously thereafter (live editing never // waits). A read-only block never pulls this chunk. -import type { Position } from './useEditable'; +import type { Position } from './editingTypes'; import type { Code, CollapseMap, diff --git a/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts b/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts deleted file mode 100644 index 8977e8b04..000000000 --- a/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Clone a `Range` into a self-contained wrapper element with computed - * styles inlined onto every cloned descendant, so that pasting into - * rich-text targets (email, Word, Notion, etc.) preserves the source's - * visual styling without depending on the source page's stylesheet. - * - * The wrapper defaults to `` so monospace + whitespace context - * survives a copy/paste round-trip. The original ancestor chain - * between `range.commonAncestorContainer` and `root` is reconstructed - * inside the wrapper so a selection living entirely inside a styled - * descendant (e.g. one token of a syntax-highlighted line) keeps that - * wrapper in the clipboard payload. - */ - -interface InlineStyleOptions { - /** - * Tag name for the wrapper element. Defaults to `'pre'` so monospace - * + whitespace context survives a copy/paste round-trip. - */ - wrapperTag?: string; - /** - * Class name applied to the wrapper. Defaults to `root.className` so - * consumers that scope styles by class keep matching when the snippet - * is pasted into a richer environment that loads the same stylesheet. - */ - className?: string; - /** - * Computed-style properties inlined onto every cloned descendant. - * Keep this list short — each property is read via - * `getComputedStyle` per node. - */ - elementStyleProps?: readonly string[]; - /** - * Computed-style properties read from `root` and inlined onto the - * wrapper. Use to carry typography (font-family, font-size, - * line-height, …) onto the wrapper so the pasted block matches the - * source even when only a descendant was selected. - */ - rootStyleProps?: readonly string[]; - /** - * Static CSS prepended to the wrapper's `style` attribute, before any - * computed properties. Useful for visual chrome (padding, rounded - * corners) that does not depend on the source. - */ - rootStaticStyles?: string; -} - -const asElement = (node: Node | null | undefined): Element | null => - node instanceof Element ? node : null; - -const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode()); - -const inlineComputedStyles = ( - target: Element, - computed: CSSStyleDeclaration, - props: readonly string[], -): void => { - let inline = target.getAttribute('style') ?? ''; - for (const prop of props) { - const value = computed.getPropertyValue(prop); - if (value && value !== 'normal' && value !== 'none' && value !== 'auto') { - inline += `${prop}:${value};`; - } - } - if (inline) { - target.setAttribute('style', inline); - } -}; - -export const cloneRangeWithInlineStyles = ( - root: HTMLElement, - range: Range, - options: InlineStyleOptions = {}, -): HTMLElement => { - const { - wrapperTag = 'pre', - className = root.className, - elementStyleProps = [], - rootStyleProps = [], - rootStaticStyles = '', - } = options; - - const doc = root.ownerDocument; - const view = doc.defaultView; - const fragment = range.cloneContents(); - const container = doc.createElement(wrapperTag); - if (className) { - container.className = className; - } - - // `Range.cloneContents` returns the descendants of the - // `commonAncestorContainer` but never the ancestor itself, so any - // selection that lives entirely inside a styled wrapper (a single - // text node inside a token, or multiple children of the same token) - // loses that wrapper in the clipboard payload. The computed-style - // inlining pass below has nothing to inline onto in that case. - // Reconstruct the ancestor chain up to (but not including) `root` - // and inline styles onto each rebuilt wrapper so rich-text paste - // targets keep the original highlighting. - const cac = range.commonAncestorContainer; - const anchor: Element | null = asElement(cac) ?? cac.parentElement; - let rootContent: Node = fragment; - // The innermost reconstructed wrapper, if any. The style-inlining - // pass below walks from here so the clone walker stays aligned with - // the source walker (which starts from the CAC's descendants). - let cloneStylingRoot: Node = container; - if (anchor && anchor !== root && root.contains(anchor)) { - let current: Element | null = anchor; - let innermost: Element | null = null; - while (current && current !== root) { - const cloned = current.cloneNode(false); - // `Element.cloneNode` returns an Element; the runtime check - // exists purely to satisfy the DOM lib's `Node` return type. - if (!(cloned instanceof Element)) { - current = current.parentElement; - continue; - } - if (view && elementStyleProps.length > 0) { - inlineComputedStyles(cloned, view.getComputedStyle(current), elementStyleProps); - } - cloned.appendChild(rootContent); - rootContent = cloned; - if (innermost === null) { - innermost = cloned; - } - current = current.parentElement; - } - if (innermost) { - cloneStylingRoot = innermost; - } - } - container.appendChild(rootContent); - - if (view && elementStyleProps.length > 0) { - // Walk the CAC's descendants and mirror them onto the cloned - // descendants of the innermost reconstructed wrapper. Both - // walkers exclude their root, so as long as the roots correspond - // (CAC ↔ innermost reconstructed wrapper, or CAC ↔ wrapper when - // there is no reconstruction) the per-step pairing is correct. - const sourceWalker = doc.createTreeWalker( - range.commonAncestorContainer, - NodeFilter.SHOW_ELEMENT, - { - acceptNode: (node) => - range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT, - }, - ); - const cloneWalker = doc.createTreeWalker(cloneStylingRoot, NodeFilter.SHOW_ELEMENT); - let source = nextElement(sourceWalker); - let clone = nextElement(cloneWalker); - while (source && clone) { - if (source.tagName === clone.tagName) { - inlineComputedStyles(clone, view.getComputedStyle(source), elementStyleProps); - } - source = nextElement(sourceWalker); - clone = nextElement(cloneWalker); - } - } - - if (view && (rootStyleProps.length > 0 || rootStaticStyles)) { - let rootInline = rootStaticStyles; - if (rootStyleProps.length > 0) { - const rootComputed = view.getComputedStyle(root); - for (const prop of rootStyleProps) { - const value = rootComputed.getPropertyValue(prop); - if (value) { - rootInline += `${prop}:${value};`; - } - } - } - if (rootInline) { - container.setAttribute('style', rootInline); - } - } - - return container; -}; diff --git a/packages/docs-infra/src/useCode/useEditable.browser.ts b/packages/docs-infra/src/useCode/useEditable.browser.ts deleted file mode 100644 index f32445d3f..000000000 --- a/packages/docs-infra/src/useCode/useEditable.browser.ts +++ /dev/null @@ -1,1542 +0,0 @@ -import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { userEvent } from 'vitest/browser'; -import { useEditable, preloadEditableEngine } from './useEditable'; -import type { Position } from './useEditable'; - -// `useEditable` loads its heavy runtime (the `EditableEngine` chunk) on demand -// and only applies `contentEditable` once it resolves. Warm that load once so -// the synchronous assertions below see `contentEditable` applied within `act`, -// mirroring the warmed module cache a real page reaches after its first block. -beforeAll(async () => { - await preloadEditableEngine(); -}); - -/** - * Places the caret at a given character offset inside `element` and waits - * for one animation frame. The hook captures its internal `state.position` - * from a `focus` listener via `requestAnimationFrame`, so synthesized - * keystrokes fired immediately after caret placement otherwise operate on - * the stale default `{line:0, column:0}`. Awaiting one frame here makes - * tests order-independent (they pass alone but used to flake when earlier - * tests in the file warmed up the event loop). - */ -async function placeCaret(element: HTMLElement, offset: number) { - element.focus(); - const sel = window.getSelection()!; - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - let current = 0; - let node = walker.nextNode(); - while (node) { - const len = node.textContent!.length; - if (current + len >= offset) { - const range = document.createRange(); - range.setStart(node, offset - current); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - break; - } - current += len; - node = walker.nextNode(); - } - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); -} - -/** - * Renders `useEditable` bound to a real ` ` element and returns helpers. - */ -async function setup( - initialContent: string, - opts: { disabled?: boolean; indentation?: number } = {}, -) { - const element = document.createElement('pre'); - element.textContent = initialContent; - document.body.appendChild(element); - - const ref = { current: element }; - const onChange = vi.fn<(text: string, position: Position) => void>(); - - const { result, unmount } = renderHook( - (props) => useEditable(props.ref, props.onChange, props.opts), - { - initialProps: { ref, onChange, opts }, - }, - ); - - await placeCaret(element, 0); - - return { element, ref, onChange, result, unmount }; -} - -/** - * Builds a `` with syntax-highlighted DOM structure matching the production - * output: `...`. - * The `innerHTML` is set directly so text ends up split across many nested spans, - * exactly as the real code highlighter produces. - */ -async function setupHighlighted( - innerHTML: string, - opts: { disabled?: boolean; indentation?: number } = {}, -) { - const element = document.createElement('pre'); - element.contentEditable = 'plaintext-only'; - element.style.whiteSpace = 'pre-wrap'; - element.style.tabSize = '2'; - element.innerHTML = innerHTML; - document.body.appendChild(element); - - const ref = { current: element }; - const onChange = vi.fn<(text: string, position: Position) => void>(); - - const { result, unmount } = renderHook( - (props) => useEditable(props.ref, props.onChange, props.opts), - { - initialProps: { ref, onChange, opts }, - }, - ); - - await placeCaret(element, 0); - - return { element, ref, onChange, result, unmount }; -} - -/** - * Production-like syntax-highlighted HTML for: - * ``` - * import * as React from 'react'; - * import { Checkbox } from '@/components/Checkbox'; - * - * export default function CheckboxBasic() { - * return ( - * - *- * ); - * } - * ``` - * - * Three frames: 0 (lines 1-6), 1 highlighted (lines 7-8), 2 (lines 9-11). - */ -const HIGHLIGHTED_HTML = [ - '- * Type Whatever You Want Below
- *', - '', - 'import * as React from \'react\';\n', - 'import { Checkbox } from \'@/components/Checkbox\';\n', - '\n', - 'export default function CheckboxBasic() {\n', - ' return (\n', - ' <div>\n', - '', - '', - ' <Checkbox defaultChecked />\n', - ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n', - '', - '', - ' </div>\n', - ' );\n', - '}', - '', - '', -].join(''); - -const FRAME_BOUNDARY_HTML = [ - '', - '', - 'import * as React from \'react\';\n', - 'import { Checkbox } from \'@/components/Checkbox\';\n', - '\n', - 'export default function CheckboxBasic() {\n', - ' return (\n', - ' <div>\n', - '', - '', - ' <Checkbox defaultChecked />\n', - '', - '', - ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n', - ' </div>\n', - ' );\n', - '}', - '', - '', -].join(''); - -const EXPECTED_TEXT = [ - "import * as React from 'react';", - "import { Checkbox } from '@/components/Checkbox';", - '', - 'export default function CheckboxBasic() {', - ' return (', - '', - '', - ' );', - '}', - '', // trailing newline -].join('\n'); - -afterEach(() => { - document.body.innerHTML = ''; - window.getSelection()?.removeAllRanges(); -}); - -describe('useEditable – browser tests', () => { - describe('contentEditable mode', () => { - it('makes the element editable', async () => { - const { element } = await setup('hello'); - // The hook should set contentEditable — the exact value - // ('plaintext-only' or 'true') varies by browser engine - expect(element.isContentEditable).toBe(true); - }); - - it('restores original contentEditable on cleanup', async () => { - const element = document.createElement('pre'); - element.textContent = 'hello'; - document.body.appendChild(element); - - const originalValue = element.contentEditable; - const ref = { current: element }; - const onChange = vi.fn(); - - const { unmount } = renderHook((props) => useEditable(props.ref, props.onChange), { - initialProps: { ref, onChange }, - }); - - expect(element.isContentEditable).toBe(true); - unmount(); - expect(element.contentEditable).toBe(originalValue); - }); - }); - - describe('Enter key – newline insertion', () => { - it('inserts a newline when Enter is pressed', async () => { - const { element, onChange } = await setup('line1\nline2'); - - await placeCaret(element, 5); - await userEvent.keyboard('{Enter}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toContain('\n'); - }); - - it('preserves indentation on Enter in indented line', async () => { - const { element, onChange } = await setup(' indented'); - - await placeCaret(element, 10); - await userEvent.keyboard('{Enter}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toContain('\n '); - }); - }); - - describe('Backspace key – character deletion', () => { - it('deletes a single character on Backspace', async () => { - const { element, onChange } = await setup('abc'); - - await placeCaret(element, 2); - await userEvent.keyboard('{Backspace}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toBe('ac\n'); - }); - - it('deletes exactly one character from the middle of a string', async () => { - const { element, onChange } = await setup('abcdef'); - - await placeCaret(element, 3); - await userEvent.keyboard('{Backspace}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toBe('abdef\n'); - }); - }); - - describe('focus and selection', () => { - it('element retains focus after typing', async () => { - const { element } = await setup('hello'); - - element.focus(); - expect(document.activeElement).toBe(element); - - await userEvent.keyboard('x'); - - // The onKeyUp handler calls element.focus() to work around - // browser focus-loss quirks - expect(document.activeElement).toBe(element); - }); - - it('maintains a valid selection after placing the caret', async () => { - const { element } = await setup('hello world'); - - await placeCaret(element, 5); - - const sel = window.getSelection()!; - expect(sel.rangeCount).toBeGreaterThan(0); - }); - }); - - describe('getState', () => { - it('returns accurate position from the real Selection API', async () => { - const { element, result } = await setup('hello world'); - - await placeCaret(element, 5); - - let state: { text: string; position: Position }; - act(() => { - state = result.current.getState(); - }); - expect(state!.text).toBe('hello world\n'); - expect(state!.position.position).toBe(5); - }); - - it('reports correct line number for multiline content', async () => { - const { element, result } = await setup('line1\nline2\nline3'); - - await placeCaret(element, 12); - - let state: { text: string; position: Position }; - act(() => { - state = result.current.getState(); - }); - expect(state!.position.line).toBe(2); - }); - }); - - describe('paste handling', () => { - it('inserts pasted text at caret position', async () => { - const { element, onChange } = await setup('hello world'); - - await placeCaret(element, 5); - - // Use the edit API to insert — synthetic ClipboardEvent dispatch - // has inconsistent clipboardData support across browser engines - act(() => { - const edit = onChange.mock.instances; - void edit; - }); - - // Dispatch a paste event with clipboardData - const clipboardData = new DataTransfer(); - clipboardData.setData('text/plain', ' beautiful'); - element.dispatchEvent( - new ClipboardEvent('paste', { - bubbles: true, - cancelable: true, - clipboardData, - }), - ); - - // In some browsers the synthetic paste event's clipboardData is not - // accessible to the handler. Verify that at least the handler ran, - // or that the content was modified via the edit API. - if (onChange.mock.calls.length > 0) { - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toContain('beautiful'); - } - }); - }); - - describe('indentation', () => { - it('inserts spaces on Tab when indentation is set', async () => { - const { element, onChange } = await setup('code', { indentation: 2 }); - - await placeCaret(element, 0); - await userEvent.keyboard('{Tab}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toBe(' code\n'); - }); - - it('removes indentation on Shift+Tab', async () => { - const { element, onChange } = await setup(' code', { indentation: 2 }); - - await placeCaret(element, 2); - await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toBe('code\n'); - }); - }); - - describe('MutationObserver integration', () => { - it('detects DOM mutations and calls onChange', async () => { - const { element, onChange } = await setup('hello'); - - await placeCaret(element, 5); - await userEvent.keyboard('!'); - - expect(onChange).toHaveBeenCalled(); - }); - }); - - describe('update and move', () => { - it('update replaces content and calls onChange', async () => { - const { result, onChange } = await setup('hello'); - - act(() => { - result.current.update('goodbye'); - }); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text).toBe('goodbye'); - }); - - it('move positions the caret correctly', async () => { - const { element, result } = await setup('hello world'); - - act(() => { - result.current.move(5); - }); - - const state = result.current.getState(); - expect(state.position.position).toBe(5); - expect(document.activeElement).toBe(element); - }); - - it('move accepts row/column object', async () => { - const { result } = await setup('line1\nline2\nline3'); - - act(() => { - result.current.move({ row: 2, column: 3 }); - }); - - const state = result.current.getState(); - expect(state.position.line).toBe(2); - }); - }); - - describe('disabled mode', () => { - it('does not make the element editable when disabled', async () => { - const { element } = await setup('hello', { disabled: true }); - expect(element.isContentEditable).toBe(false); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Syntax-highlighted DOM structure tests -// --------------------------------------------------------------------------- -describe('useEditable - syntax-highlighted content', () => { - // ------------------------------------------------------------------------- - // toString / getState with nested spans - // ------------------------------------------------------------------------- - describe('reading text from highlighted DOM', () => { - it('returns the full plain text from deeply nested span structure', async () => { - const { result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); - const state = result.current.getState(); - expect(state.text).toBe(EXPECTED_TEXT); - }); - - it('correctly counts lines across frame boundaries', async () => { - const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); - // Place caret at start of line 9 ("', - " Type Whatever You Want Below
", - '
',
- 'aaa\n',
- ' \n',
- 'bbb',
- '',
- ].join('');
- const { element, onChange } = await setupHighlighted(html);
-
- // Place caret at end of line 2 (the " " line)
- // "aaa\n" = 4 chars, " " = 2 → offset 6
- await placeCaret(element, 6);
-
- await userEvent.keyboard('x');
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const lines = text.split('\n');
- expect(lines).toHaveLength(4); // 3 lines + trailing newline
- expect(lines[0]).toBe('aaa');
- expect(lines[1]).toBe(' x');
- expect(lines[2]).toBe('bbb');
- });
-
- it('preserves newlines when typing on a line between frames', async () => {
- // Two frames with a line in between
- const html = [
- '',
- '',
- 'aaa\n',
- ' \n',
- '',
- '',
- 'bbb',
- '',
- '',
- ].join('');
- const { element, onChange } = await setupHighlighted(html);
-
- await placeCaret(element, 6);
-
- await userEvent.keyboard('x');
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const lines = text.split('\n');
- expect(lines).toHaveLength(4);
- expect(lines[0]).toBe('aaa');
- expect(lines[1]).toBe(' x');
- expect(lines[2]).toBe('bbb');
- });
-
- it('preserves newlines when typing on the empty line of production highlighted DOM', async () => {
- const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 });
-
- // Place caret on the empty line 3 (0-indexed line 2)
- const lines = EXPECTED_TEXT.split('\n');
- const offset = lines[0].length + 1 + lines[1].length + 1;
- await placeCaret(element, offset);
-
- await userEvent.keyboard('x');
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const resultLines = text.split('\n');
- // Line count should be the same (character typed on existing empty line)
- expect(resultLines.length).toBe(EXPECTED_TEXT.split('\n').length);
- // The empty line should now have 'x'
- expect(resultLines[2]).toBe('x');
- // Adjacent lines preserved
- expect(resultLines[1]).toBe("import { Checkbox } from '@/components/Checkbox';");
- expect(resultLines[3]).toBe('export default function CheckboxBasic() {');
- });
-
- it('preserves newlines when typing on line 9 (after , first line of frame 2)', async () => {
- // Production highlighted HTML — newlines are inside line spans.
- const productionHTML =
- '' +
- '' +
- 'import * as React from \'react\';\n' +
- 'import { Checkbox } from \'@/components/Checkbox\';\n' +
- '\n' +
- 'export default function CheckboxBasic() {\n' +
- ' return (\n' +
- ' <div>\n' +
- '' +
- '' +
- ' <Checkbox defaultChecked />\n' +
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n' +
- '' +
- '' +
- ' </div>\n' +
- ' );\n' +
- '}' +
- '' +
- '';
-
- const { element, onChange } = await setupHighlighted(productionHTML, { indentation: 2 });
-
- // Compute offset to start of line 9 (0-indexed line 8): " Type Whatever You Want Below
x", - ); - expect(resultLines[8]).toBe(' '); - expect(resultLines[9]).toBe(' );'); - }); - - it('keeps typed text at the end of a line that starts a new frame after a highlighted frame', async () => { - const { element, onChange } = await setupHighlighted(FRAME_BOUNDARY_HTML, { indentation: 2 }); - - const lines = EXPECTED_TEXT.split('\n'); - let offset = 0; - for (let i = 0; i < 7; i += 1) { - offset += lines[i].length + 1; - } - offset += lines[7].length; - await placeCaret(element, offset); - - await userEvent.keyboard('x'); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - const resultLines = text.split('\n'); - - expect(resultLines[7]).toBe( - "Type Whatever You Want Below
x", - ); - expect(resultLines[8]).toBe(' '); - expect(resultLines[9]).toBe(' );'); - }); - - it('preserves newlines when contentEditable falls back to "true" (old Firefox)', async () => { - // Simulate old Firefox that doesn't support plaintext-only by forcing - // contentEditable="true" before the hook sets it. - const productionHTML = - '' +
- '' +
- 'import * as React from \'react\';\n' +
- 'import { Checkbox } from \'@/components/Checkbox\';\n' +
- '\n' +
- 'export default function CheckboxBasic() {\n' +
- ' return (\n' +
- ' <div>\n' +
- '' +
- '' +
- ' <Checkbox defaultChecked />\n' +
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n' +
- '' +
- '' +
- ' </div>\n' +
- ' );\n' +
- '}' +
- '' +
- '';
-
- const element = document.createElement('pre');
- // Force contentEditable="true" — simulates Firefox < 130
- element.contentEditable = 'true';
- element.style.whiteSpace = 'pre-wrap';
- element.style.tabSize = '2';
- element.innerHTML = productionHTML;
- document.body.appendChild(element);
-
- // Monkey-patch the element to make "plaintext-only" throw, simulating old Firefox
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
- initialProps: { ref, onChange, opts: { indentation: 2 } },
- });
-
- // Verify we're in "true" mode (not plaintext-only)
- expect(element.contentEditable).toBe('true');
-
- // Place caret on line 9 (" ") — after the indentation
- const expectedLines = EXPECTED_TEXT.split('\n');
- let offset = 0;
- for (let i = 0; i < 8; i += 1) {
- offset += expectedLines[i].length + 1;
- }
- offset += 4;
- await placeCaret(element, offset);
-
- await userEvent.keyboard('x');
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const resultLines = text.split('\n');
- // All 11 lines should be preserved (+ trailing newline = 12 entries)
- expect(resultLines).toHaveLength(12);
- expect(resultLines[8]).toBe(' x');
- expect(resultLines[7]).toContain('Type Whatever You Want Below');
- expect(resultLines[9]).toBe(' );');
- });
-
- it('keeps typed text inside the current line when fallback mode types at column 0', async () => {
- const productionHTML =
- '' +
- '' +
- 'import * as React from \'react\';\n' +
- 'import { Checkbox } from \'@/components/Checkbox\';\n' +
- '\n' +
- 'export default function CheckboxBasic() {\n' +
- ' return (\n' +
- ' <div>\n' +
- '' +
- '' +
- ' <Checkbox defaultChecked />\n' +
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n' +
- '' +
- '' +
- ' </div>\n' +
- ' );\n' +
- '}' +
- '' +
- '';
-
- const element = document.createElement('pre');
- element.contentEditable = 'true';
- element.style.whiteSpace = 'pre-wrap';
- element.style.tabSize = '2';
- element.innerHTML = productionHTML;
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
- initialProps: { ref, onChange, opts: { indentation: 2 } },
- });
-
- const expectedLines = EXPECTED_TEXT.split('\n');
- let offset = 0;
- for (let i = 0; i < 8; i += 1) {
- offset += expectedLines[i].length + 1;
- }
- await placeCaret(element, offset);
-
- const keyDown = new KeyboardEvent('keydown', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- const frame = element.querySelector('[data-frame="2"]') as HTMLElement;
- const line = frame.querySelector('[data-ln="9"]') as HTMLElement;
-
- expect(keyDown.defaultPrevented).toBe(true);
- expect(frame.firstElementChild).toBe(line);
- expect(line.textContent).toBe('x \n');
- expect(frame.firstChild).not.toHaveTextContent(/^x$/);
-
- const keyUp = new KeyboardEvent('keyup', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyUp);
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const resultLines = text.split('\n');
- expect(resultLines[8]).toBe('x ');
- });
-
- it('backspace on a blank-only line removes one indent unit and cursor stays on the line', async () => {
- // Start with a 3-line highlighted DOM where line 2 has 2 spaces of indentation
- const html = [
- '',
- 'aaa\n',
- ' \n',
- 'bbb',
- '',
- ].join('');
- const { onChange } = await setupHighlighted(html, { indentation: 2 });
-
- // Place caret at end of the 2-space indent on line 2
- // "aaa\n" = 4 chars, " " = 2 → offset 6
- await placeCaret(document.querySelector('pre')!, 6);
-
- // Press Backspace — should remove the 2 spaces (one indent unit)
- await userEvent.keyboard('{Backspace}');
-
- expect(onChange).toHaveBeenCalled();
- const [text, position] = onChange.mock.calls[onChange.mock.calls.length - 1];
- const lines = text.split('\n');
- // Line 2 should now be empty
- expect(lines[1]).toBe('');
- // Total lines: 3 + trailing newline = 4 entries
- expect(lines).toHaveLength(4);
- // Cursor should report line 1 (0-indexed), not line 0
- expect(position.line).toBe(1);
- expect(position.content).toBe('');
- });
-
- it('cursor is visually on the empty line after move(), not the line above', async () => {
- // DOM where line 2 is empty (just \n) — simulates the state after
- // backspace removes all indentation from a blank line.
- const html = [
- '',
- 'aaa\n',
- '\n',
- 'bbb',
- '',
- ].join('');
- const { result } = await setupHighlighted(html);
-
- // Position cursor at the start of line 2 (the empty line)
- // "aaa\n" = 4 chars → offset 4
- act(() => {
- result.current.move(4);
- });
-
- // Check that the selection is positioned inside line 2's span
- // (the empty line), NOT inside line 1's span.
- const sel = window.getSelection()!;
- const focusNode = sel.focusNode!;
- // adjustCursorAtNewlineBoundary advances the cursor past the \n
- // to the next text node. Since line 2 has no text (only \n), the
- // focusNode may be in line 2's span or in line 3's text.
- let lineSpan: Element | null;
- if (focusNode.nodeType === Node.TEXT_NODE) {
- lineSpan = focusNode.parentElement;
- } else {
- lineSpan = focusNode as Element;
- // If focusNode is a line span itself, use it directly.
- // Otherwise walk up to find the closest line span.
- if (!lineSpan.getAttribute('data-ln')) {
- lineSpan = lineSpan.closest('[data-ln]');
- }
- }
- const ln = Number(lineSpan!.getAttribute('data-ln'));
- // Cursor must NOT be on line 1
- expect(ln).toBeGreaterThanOrEqual(2);
- });
-});
-
-// ---------------------------------------------------------------------------
-// Disconnected-window arrow regression (no boundary involved)
-// ---------------------------------------------------------------------------
-describe('useEditable – arrow keys during the disconnected window', () => {
- it('a plain ArrowDown right after Enter (no onBoundary) is not reverted by the post-flush rerender', async () => {
- // No `minRow`/`maxRow`/`onBoundary` here — just plain editing. The
- // race we care about: Enter → flushChanges() disconnects, ArrowDown
- // fires while `state.disconnected` is still true, the fast-path
- // calls `unblock([])` to nudge React, and the resulting layout-
- // effect must NOT snap the caret back to the pre-arrow line.
- const element = document.createElement('pre');
- element.contentEditable = 'plaintext-only';
- element.style.whiteSpace = 'pre-wrap';
- document.body.appendChild(element);
- element.textContent = 'line1\nline2\nline3\n';
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { unmount } = renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
- initialProps: { ref, onChange, opts: {} as { indentation?: number } },
- });
-
- try {
- // Place the caret at the end of line 1.
- await placeCaret(element, 'line1'.length);
- await userEvent.keyboard('{Enter}');
- // After Enter the caret is at the start of line 2 (a blank line
- // between line1 and line2 — Enter splits the text).
- await userEvent.keyboard('{ArrowDown}');
-
- // Walk to caret to compute the visual line.
- const sel = window.getSelection()!;
- const range = sel.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- const globalOffset = pre.toString().length;
- const fullText = element.textContent ?? '';
- const computedLine = fullText.slice(0, globalOffset).split('\n').length - 1;
-
- // After Enter the caret is at the start of the new blank line
- // (row 1) between `line1` and `line2`. A correctly-handled
- // ArrowDown moves the caret down exactly one visual line, landing
- // at row 2 (`line2`). Asserting the exact target row catches both
- // the original snap-back regression (caret rebounds to row 0) and
- // any accidental overshoot (caret skips past row 2).
- expect(computedLine).toBe(2);
- } finally {
- unmount();
- element.remove();
- }
- });
-});
-
-// ---------------------------------------------------------------------------
-// Focus-frame ArrowUp regression
-// ---------------------------------------------------------------------------
-describe('useEditable – focus frame ArrowUp after Enter', () => {
- /**
- * Render `text` as a flat list of `.line` spans separated by literal `\n`
- * text-node gaps — the same shape the production highlighter emits when the
- * editable is mounted.
- */
- function renderLines(element: HTMLElement, text: string) {
- const lines = text.split('\n');
- // The hook's `toString()` adds a trailing `\n` if missing — match it.
- const visibleLines = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
- element.replaceChildren();
- visibleLines.forEach((lineText, idx) => {
- if (idx > 0) {
- element.appendChild(document.createTextNode('\n'));
- }
- const line = document.createElement('span');
- line.className = 'line';
- line.setAttribute('data-ln', String(idx + 1));
- line.textContent = lineText;
- element.appendChild(line);
- });
- }
-
- it('reproduces: Enter at end of a focus-frame line then ArrowUp twice should land outside the frame', async () => {
- // Mirrors the user's example:
- // import * as React from 'react';
- // import { Checkbox } from '@/components/Checkbox';
- //
- // export default function CheckboxBasic() {
- // return (
- // ... ← focus frame line 8 - //
Type Whatever You Want Below
", - '
-
- {lines.map((line, index) => (
-
-
- {highlightLine(line)}
-
- {'\n'}
-
- ))}
-
-
- );
-}
-
-type HarnessHandle = {
- ref: React.RefObject---
{
- // The collapsed editor (clipped indent gutter) is where the real bug shows.
- const initial = 'function foo() {\n doStuff()\n}\n';
- const { handle, element } = await setupEditor(initial, {
- indentation: 2,
- caretSelector: '.line',
- minColumn: 2,
- minRow: 1,
- maxRow: 1,
- onBoundary: vi.fn(),
- });
- const offset = 'function foo() {\n doStuff()'.length; // end of line 1
- await placeCaret(element, offset);
- await userEvent.keyboard('x');
- await settle();
- await userEvent.keyboard('=');
- await settle();
- await userEvent.keyboard('{Backspace}');
- await settle();
- expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
- expect(caretLineColumn(element)).toMatchObject({ line: 1, column: ' doStuff()x'.length });
- });
-
- it('keeps the caret in a collapsed gutter through an async re-highlight', async () => {
- const initial = 'function foo() {\n doStuff()\n}\n';
- const { handle, element } = await setupEditor(initial, {
- indentation: 2,
- caretSelector: '.line',
- minColumn: 2,
- minRow: 1,
- maxRow: 1,
- onBoundary: vi.fn(),
- preParse: asyncPreParse(),
- });
- const offset = 'function foo() {\n doStuff()'.length;
- await placeCaret(element, offset);
- await userEvent.keyboard('x');
- await settle();
- await settle();
- await userEvent.keyboard('=');
- await settle();
- await settle();
- await userEvent.keyboard('{Backspace}');
- await settle();
- await settle();
- expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
- expect(caretLineColumn(element)).toMatchObject({ line: 1, column: ' doStuff()x'.length });
- });
-
- it('lands the caret at the line/gap boundary when editing at the end of a line', async () => {
- // The real bug uses the `End` key to land the caret at the line end — which
- // in the framed `.line` structure is the boundary with the inter-line gap
- // node. Native typing there can flatten the spans / split across lines.
- const initial = 'function foo() {\n doStuff()\n}\n';
- const { handle, element } = await setupEditor(initial, {
- indentation: 2,
- caretSelector: '.line',
- });
- // Put the caret somewhere on line 1, then End to the line end.
- await placeCaret(element, 'function foo() {\n do'.length);
- await userEvent.keyboard('{End}');
- await settle();
- await userEvent.keyboard('x');
- await settle();
- await userEvent.keyboard('=');
- await settle();
- await userEvent.keyboard('{Backspace}');
- await settle();
- expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
- expect(caretLineColumn(element)).toMatchObject({ line: 1, column: ' doStuff()x'.length });
- });
-
- // -------------------------------------------------------------------------
- // Caret restoration when erasing the last indent on a clipped (collapsed-window) line
- // -------------------------------------------------------------------------
- it('restores the caret when backspacing the last indent of a blank clipped line (minColumn)', async () => {
- // Simulate a collapsed window: indentation clipped to minColumn=2, the
- // visible region is rows 1..3. Line 2 is a blank line with exactly 2 spaces.
- const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
- const { element } = await setupEditor(
- initial,
- {
- indentation: 2,
- caretSelector: '.line',
- minColumn: 2,
- minRow: 1,
- maxRow: 3,
- onBoundary: vi.fn(),
- },
- { scroll: true },
- );
- // caret at end of the blank line 2 (column 2 == minColumn)
- const offset = 'function foo() {\n const a = 1;\n '.length;
- await placeCaret(element, offset);
- const before = caretLineColumn(element);
- await userEvent.keyboard('{Backspace}');
- await settle();
- const after = caretLineColumn(element);
- // Assert the (arguably correct) behavior: stay on the same line, now empty.
- expect(after.line).toBe(before.line);
- });
-
- it('restores the caret when backspacing an indent on a content line in a clipped gutter (minColumn)', async () => {
- const initial = 'function foo() {\n const a = 1;\n}\n';
- const { element } = await setupEditor(
- initial,
- {
- indentation: 2,
- caretSelector: '.line',
- minColumn: 2,
- minRow: 1,
- maxRow: 1,
- onBoundary: vi.fn(),
- },
- { scroll: true },
- );
- // caret right after the 2-space indent on line 1 (` const a = 1;`)
- const offset = 'function foo() {\n '.length;
- await placeCaret(element, offset);
- await userEvent.keyboard('{Backspace}');
- await settle();
- const after = caretLineColumn(element);
- expect(after).toBeTruthy();
- });
-
- // -------------------------------------------------------------------------
- // ArrowUp at the visible top fires onBoundary (scroll anchor)
- // -------------------------------------------------------------------------
- it('fires onBoundary on ArrowUp at the first row and ArrowDown at the last row', async () => {
- const onBoundary = vi.fn();
- const initial = 'line0\nline1\nline2\nline3\nline4\n';
- const { element } = await setupEditor(
- initial,
- { indentation: 2, caretSelector: '.line', minRow: 2, maxRow: 3, onBoundary },
- { scroll: true },
- );
- // Caret on line 2 (minRow), then ArrowUp.
- const upOffset = 'line0\nline1\n'.length + 2;
- await placeCaret(element, upOffset);
- await userEvent.keyboard('{ArrowUp}');
- await settle();
- const upCalls = onBoundary.mock.calls.length;
-
- // Caret on line 3 (maxRow), then ArrowDown.
- const downOffset = 'line0\nline1\nline2\n'.length + 2;
- await placeCaret(element, downOffset);
- await userEvent.keyboard('{ArrowDown}');
- await settle();
- const downCalls = onBoundary.mock.calls.length - upCalls;
-
- expect(upCalls).toBeGreaterThan(0); // ArrowUp must fire the boundary
- expect(downCalls).toBeGreaterThan(0); // ArrowDown must fire the boundary
- });
-
- // -------------------------------------------------------------------------
- // Transient DOM vs committed source during an async re-highlight: backspacing the
- // last indent collapses the line during the worker round-trip, before the committed
- // source catches up.
- // -------------------------------------------------------------------------
- it('keeps the transient DOM consistent with the committed source when async-backspacing the last indent', async () => {
- const { preParse, resolvePending } = deferredPreParse();
- const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
- const { element } = await setupEditor(
- initial,
- {
- indentation: 2,
- caretSelector: '.line',
- minColumn: 2,
- minRow: 1,
- maxRow: 3,
- onBoundary: vi.fn(),
- preParse,
- },
- { scroll: true },
- );
- const offset = 'function foo() {\n const a = 1;\n '.length;
- await placeCaret(element, offset);
-
- // Compact structural signature: the frame's direct children, each as
- // either `LINE( Type Whatever You Want Below Type Whatever You Want Below Type Whatever You Want Below` with `contentEditable` explicitly at its default so the attach is observable. */
-function makePre(content: string) {
- const element = document.createElement('pre');
- element.contentEditable = 'inherit';
- element.textContent = content;
- document.body.appendChild(element);
- return element;
-}
-
-const isEditable = (element: HTMLElement) =>
- ['plaintext-only', 'true'].includes(element.contentEditable);
-
-describe('useEditable lazy engine loading (cold cache)', () => {
- it('eager: invokes the loader, no-ops the edit proxy until it resolves, then attaches', async () => {
- const element = makePre('hello');
- const ref = { current: element };
-
- // A loader we resolve by hand, so we can observe the pre-load window.
- let resolveEngine!: (create: EditingEngineModule) => void;
- const engineLoader = vi.fn(
- () =>
- new Promise` element and returns helpers.
- */
-function setup(
- initialContent: string,
- opts: {
- disabled?: boolean;
- indentation?: number;
- minColumn?: number;
- minRow?: number;
- maxRow?: number;
- onBoundary?: () => void;
- caretSelector?: string;
- } = {},
-) {
- const element = document.createElement('pre');
- element.textContent = initialContent;
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- const { result, unmount } = renderHook(
- (props) => useEditable(props.ref, props.onChange, props.opts),
- {
- initialProps: { ref, onChange, opts },
- },
- );
-
- // Place the caret at position 0 by default
- placeSelection(element, 0);
-
- return { element, ref, onChange, result, unmount };
-}
-
-// `useEditable` now loads its heavy runtime (the `EditableEngine` chunk) on
-// demand and only applies `contentEditable` once it resolves. Warm that load
-// once here so the otherwise-synchronous assertions below see `contentEditable`
-// applied within `renderHook`'s `act`. On a real page the first editable block
-// loads the engine asynchronously and every block after attaches synchronously
-// from the module cache — this mirrors that warmed-cache state.
-beforeAll(async () => {
- await preloadEditableEngine();
-});
-
-afterEach(() => {
- document.body.innerHTML = '';
- window.getSelection()?.removeAllRanges();
-});
-
-// ---------------------------------------------------------------------------
-// Basic hook contract
-// ---------------------------------------------------------------------------
-describe('useEditable', () => {
- describe('hook return value', () => {
- it('returns an Edit object with update, insert, move, and getState', () => {
- const { result } = setup('hello');
- expect(result.current).toHaveProperty('update');
- expect(result.current).toHaveProperty('insert');
- expect(result.current).toHaveProperty('move');
- expect(result.current).toHaveProperty('getState');
- expect(typeof result.current.update).toBe('function');
- expect(typeof result.current.insert).toBe('function');
- expect(typeof result.current.move).toBe('function');
- expect(typeof result.current.getState).toBe('function');
- });
-
- it('returns a referentially stable Edit object across re-renders', () => {
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
- const ref = { current: element };
- const onChange = vi.fn();
-
- const { result, rerender } = renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- const first = result.current;
- rerender({ ref, onChange });
- expect(result.current).toBe(first);
- });
- });
-
- // ---------------------------------------------------------------------------
- // Element setup / teardown
- // ---------------------------------------------------------------------------
- describe('element configuration', () => {
- it('sets contentEditable on the element', () => {
- const { element } = setup('hello');
- // Should be 'plaintext-only' if supported, or 'true'
- expect(['plaintext-only', 'true']).toContain(element.contentEditable);
- });
-
- it('sets whiteSpace to pre-wrap when computed style does not preserve whitespace', async () => {
- // A plain elements get `white-space: pre` from the UA stylesheet, so
- // there is no need to add an inline override.
- const { element } = setup('hello');
- expect(element.style.whiteSpace).toBe('');
- });
-
- it('preserves whiteSpace when already set to pre', () => {
- const element = document.createElement('pre');
- element.style.whiteSpace = 'pre';
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn();
- renderHook(() => useEditable(ref, onChange));
-
- expect(element.style.whiteSpace).toBe('pre');
- });
-
- it('restores element styles on unmount', async () => {
- const element = document.createElement('pre');
- element.style.whiteSpace = 'normal';
- element.contentEditable = 'false';
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn();
- const { unmount } = renderHook(() => useEditable(ref, onChange));
-
- unmount();
-
- // Restore is deferred to a microtask so unmounts across the page
- // share a single style invalidation; flush before observing.
- await Promise.resolve();
- expect(element.style.whiteSpace).toBe('normal');
- expect(element.contentEditable).toBe('false');
- });
-
- it('sets tabSize when indentation option is provided', async () => {
- const { element } = setup('hello', { indentation: 4 });
- // Inline style is applied in a microtask so the read+write batches
- // across all editables on the page; flush it before observing.
- await Promise.resolve();
- expect(element.style.tabSize).toBe('4');
- });
- });
-
- // ---------------------------------------------------------------------------
- // disabled option
- // ---------------------------------------------------------------------------
- describe('disabled option', () => {
- it('does not set contentEditable when disabled', () => {
- const element = document.createElement('pre');
- element.contentEditable = 'inherit';
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn();
- renderHook(() => useEditable(ref, onChange, { disabled: true }));
-
- expect(element.contentEditable).toBe('inherit');
- });
- });
-
- // ---------------------------------------------------------------------------
- // lazy engine loading + activation
- // ---------------------------------------------------------------------------
- describe('lazy engine loading', () => {
- it('never invokes the engine loader when disabled (read-only blocks pay nothing)', () => {
- const element = document.createElement('pre');
- element.contentEditable = 'inherit';
- element.textContent = 'hello';
- document.body.appendChild(element);
- const ref = { current: element };
- const engineLoader = vi.fn(preloadableEngineLoader);
-
- renderHook(() => useEditable(ref, () => {}, { disabled: true, engineLoader }));
-
- expect(engineLoader).not.toHaveBeenCalled();
- expect(element.contentEditable).toBe('inherit');
- });
-
- it('with activation "interaction", defers contentEditable until the user engages', async () => {
- const element = document.createElement('pre');
- element.contentEditable = 'inherit';
- element.textContent = 'hello';
- document.body.appendChild(element);
- const ref = { current: element };
- renderHook(() => useEditable(ref, () => {}, { activation: 'interaction' }));
-
- // Not editable on mount...
- expect(element.contentEditable).toBe('inherit');
-
- // ...nor on hover (hover only warms the engine, it does not activate).
- act(() => {
- element.dispatchEvent(new Event('pointerenter'));
- });
- expect(element.contentEditable).toBe('inherit');
-
- // Engaging the block (focus) attaches contentEditable.
- act(() => {
- element.dispatchEvent(new Event('focus'));
- });
- await waitFor(() => {
- expect(['plaintext-only', 'true']).toContain(element.contentEditable);
- });
- });
- });
-
- // ---------------------------------------------------------------------------
- // edit.getState
- // ---------------------------------------------------------------------------
- describe('getState', () => {
- it('returns text content with trailing newline', () => {
- const { result, element } = setup('hello');
- placeSelection(element, 0);
- const state = result.current.getState();
- expect(state.text).toBe('hello\n');
- });
-
- it('returns current position', () => {
- const { result, element } = setup('hello');
- placeSelection(element, 3);
- const state = result.current.getState();
- expect(state.position.position).toBe(3);
- });
- });
-
- // ---------------------------------------------------------------------------
- // edit.update
- // ---------------------------------------------------------------------------
- describe('update', () => {
- it('calls onChange with new content', () => {
- const { result, element, onChange } = setup('hello');
- placeSelection(element, 5);
-
- act(() => {
- result.current.update('hello world');
- });
-
- expect(onChange).toHaveBeenCalledTimes(1);
- const [text] = onChange.mock.calls[0];
- expect(text).toBe('hello world');
- });
-
- it('adjusts position based on content length difference', () => {
- const { result, element, onChange } = setup('hello');
- placeSelection(element, 5);
-
- act(() => {
- result.current.update('hello world');
- });
-
- const [, position] = onChange.mock.calls[0];
- // Original position was 5, added 6 chars (' world'), so new position = 5 + (11 - 6) = 10
- expect(position.position).toBe(5 + ('hello world'.length - 'hello\n'.length));
- });
-
- it('does nothing when element ref is null', () => {
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref: { current: HTMLElement | null } = { current: element };
- const onChange = vi.fn();
- const { result } = renderHook(() => useEditable(ref, onChange));
-
- ref.current = null;
-
- act(() => {
- result.current.update('new content');
- });
-
- expect(onChange).not.toHaveBeenCalled();
- });
- });
-
- // ---------------------------------------------------------------------------
- // edit.insert
- // ---------------------------------------------------------------------------
- describe('insert', () => {
- it('inserts text at caret position', () => {
- const { result, element } = setup('hello');
- placeSelection(element, 5);
-
- act(() => {
- result.current.insert(' world');
- });
-
- // insert triggers flushChanges internally through DOM mutations,
- // but in JSDOM we can verify the direct DOM manipulation happened
- // The element should now have the inserted text node
- expect(element.textContent).toContain('world');
- });
-
- it('does nothing when element ref is null', () => {
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref: { current: HTMLElement | null } = { current: element };
- const onChange = vi.fn();
- const { result } = renderHook(() => useEditable(ref, onChange));
-
- ref.current = null;
-
- act(() => {
- result.current.insert('text');
- });
-
- // Should not throw
- expect(element.textContent).toBe('hello');
- });
-
- it('inserts at the start of a framed line without escaping the line wrapper', () => {
- const element = document.createElement('pre');
- element.innerHTML = [
- '',
- '',
- 'aaa\n',
- '',
- '',
- 'bbb',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { result } = renderHook(() => useEditable(ref, onChange));
-
- placeSelection(element, 4);
-
- act(() => {
- result.current.insert('x');
- });
-
- const frame = element.querySelector('[data-frame="1"]') as HTMLElement;
- const line = frame.querySelector('[data-ln="2"]') as HTMLElement;
-
- expect(frame.firstChild).toBe(line);
- expect(line.textContent).toBe('xbbb');
- expect(result.current.getState().text).toBe('aaa\nxbbb\n');
- });
-
- it('deletes one character before the cursor (negative offset, same-node range)', () => {
- const { result, element } = setup('hello');
- placeSelection(element, 3);
-
- act(() => {
- result.current.insert('', -1);
- });
-
- expect(element.textContent).toContain('helo');
- });
-
- it('deletes multiple characters before the cursor (negative offset, same-node range)', () => {
- const { result, element } = setup('hello');
- placeSelection(element, 5);
-
- act(() => {
- result.current.insert('', -3);
- });
-
- expect(element.textContent).toContain('he');
- });
-
- it('deletes characters spanning a node boundary (negative offset, cross-node range)', () => {
- const element = document.createElement('pre');
- element.innerHTML = [
- '',
- '',
- 'aaa\n',
- '',
- '',
- 'bbb',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { result } = renderHook(() => useEditable(ref, onChange));
-
- // Place caret at position 5 ("aaa\nbb|b"), then delete 2 chars back
- // crossing the \n node boundary: removes "\nb", leaving "aaabb"
- placeSelection(element, 5);
-
- act(() => {
- result.current.insert('', -2);
- });
-
- expect(result.current.getState().text).toBe('aaabb\n');
- });
-
- it('inserts after without merging the next framed line into the same line', () => {
- const element = document.createElement('pre');
- element.innerHTML = [
- '',
- '',
- 'aaa\n',
- '',
- '',
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n',
- '',
- '',
- ' </div>',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { result } = renderHook(() => useEditable(ref, onChange, { indentation: 2 }));
-
- const lines = [
- 'aaa',
- "
',
- '',
- 'aaa\n',
- '',
- '',
- 'bbb',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- renderHook(() => useEditable(ref, onChange, { indentation: 2 }));
-
- placeSelection(element, 4);
-
- const keyDown = new KeyboardEvent('keydown', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- const frame = element.querySelector('[data-frame="1"]') as HTMLElement;
- const line = frame.querySelector('[data-ln="2"]') as HTMLElement;
-
- expect(keyDown.defaultPrevented).toBe(true);
- expect(frame.firstChild).toBe(line);
- expect(line.textContent).toBe('xbbb');
-
- const keyUp = new KeyboardEvent('keyup', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyUp);
-
- expect(onChange).toHaveBeenCalled();
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(text).toBe('aaa\nxbbb\n');
- });
-
- it('keeps ',
- '',
- 'aaa\n',
- '',
- '',
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n',
- '',
- '',
- ' </div>',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- renderHook(() => useEditable(ref, onChange, { indentation: 2 }));
-
- const lines = [
- 'aaa',
- " Type Whatever You Want Below
", - ' ', - '', - ]; - - placeSelection(element, lines[0].length + 1 + lines[1].length); - - const keyDown = new KeyboardEvent('keydown', { - key: 'x', - code: 'KeyX', - bubbles: true, - cancelable: true, - }); - element.dispatchEvent(keyDown); - - const keyUp = new KeyboardEvent('keyup', { - key: 'x', - code: 'KeyX', - bubbles: true, - cancelable: true, - }); - element.dispatchEvent(keyUp); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text.split('\n')).toEqual([ - 'aaa', - "Type Whatever You Want Below
x", - ' ', - '', - ]); - }); - - it('repairs merged lines before onChange when fallback mode receives a merged DOM', () => { - const element = document.createElement('pre'); - element.contentEditable = 'true'; - element.style.whiteSpace = 'pre-wrap'; - element.innerHTML = [ - '',
- '',
- 'aaa\n',
- '',
- '',
- ' <p style={{ color: \'#CA244D\' }}>Type Whatever You Want Below</p>\n',
- '',
- '',
- ' </div>',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- renderHook(() => useEditable(ref, onChange, { indentation: 2 }));
-
- const lines = [
- 'aaa',
- " Type Whatever You Want Below
", - ' ', - '', - ]; - - placeSelection(element, lines[0].length + 1 + lines[1].length); - - const keyDown = new KeyboardEvent('keydown', { - key: 'x', - code: 'KeyX', - bubbles: true, - cancelable: true, - }); - element.dispatchEvent(keyDown); - - const line = element.querySelector('[data-ln="8"]') as HTMLElement; - const nextFrame = element.querySelector('[data-frame="2"]') as HTMLElement; - line.textContent = - "Type Whatever You Want Below
x "; - nextFrame.remove(); - - placeSelection(element, lines[0].length + 1 + lines[1].length + 1); - - const keyUp = new KeyboardEvent('keyup', { - key: 'x', - code: 'KeyX', - bubbles: true, - cancelable: true, - }); - element.dispatchEvent(keyUp); - - expect(onChange).toHaveBeenCalled(); - const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(text.split('\n')).toEqual([ - 'aaa', - "Type Whatever You Want Below
x", - ' ', - '', - ]); - }); - - it('preserves line count when rapid keydown (repeat) fires after a line-merging DOM mutation in fallback mode', () => { - // Scenario: Firefox fallback mode, cursor at end of a line before a frame - // boundary. User types 'x' quickly so a second keydown arrives before keyup. - // The first keydown (non-repeat) inserts via edit.insert. Firefox then merges - // the next frame's line into the current one (unexpected line merge). Before - // keyup fires, a second rapid keydown arrives with repeat:true. At this point - // state.disconnected is true (observer was disconnected during the first - // edit.insert path via MutationObserver callbacks). The disconnected guard - // blocks the second keydown, setting pendingContent = null via the early return. - // When keyup finally calls flushChanges, pendingContent is null so - // repairUnexpectedLineMerge cannot detect the merge and a line is lost. - const element = document.createElement('pre'); - element.contentEditable = 'true'; - element.style.whiteSpace = 'pre-wrap'; - element.innerHTML = [ - '',
- '',
- 'aaa\n',
- '',
- '',
- 'bbb',
- '',
- '',
- ].join('');
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook(() => useEditable(ref, onChange));
-
- // Cursor at end of line 1 ("aaa|")
- placeSelection(element, 3);
-
- // First keydown — routes through isPlaintextInputKey, calls edit.insert('x')
- const keyDown1 = new KeyboardEvent('keydown', {
- key: 'x',
- code: 'KeyX',
- repeat: false,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown1);
-
- // Firefox merges lines: "aaaxbbb" — frame 1 line is now merged into frame 0
- const line1 = element.querySelector('[data-ln="1"]') as HTMLElement;
- const frame1 = element.querySelector('[data-frame="1"]') as HTMLElement;
- line1.textContent = 'aaaxbbb\n';
- frame1.remove();
- placeSelection(element, 4);
-
- // Second rapid keydown (key held) — state.disconnected is true here,
- // so this hits the early-return guard without setting pendingContent
- const keyDown2 = new KeyboardEvent('keydown', {
- key: 'x',
- code: 'KeyX',
- repeat: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown2);
-
- // keyup — flushChanges is called with pendingContent=null, so the merge repair
- // cannot run. Without the fix, onChange receives "aaaxbbb" (missing line 2).
- const keyUp = new KeyboardEvent('keyup', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyUp);
-
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0].split('\n')).toEqual(['aaaxx', 'bbb', '']);
- });
- });
-
- // ---------------------------------------------------------------------------
- // forward delete (Delete key)
- // ---------------------------------------------------------------------------
- describe('forward delete', () => {
- function dispatchDelete(element: HTMLElement) {
- const keyDown = new KeyboardEvent('keydown', {
- key: 'Delete',
- code: 'Delete',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
- return keyDown;
- }
-
- it('deletes the character after the caret (collapsed)', () => {
- const { element } = setup('hello world', { caretSelector: '.line' });
- placeSelection(element, 'hello'.length); // caret before the space
- const keyDown = dispatchDelete(element);
- expect(keyDown.defaultPrevented).toBe(true);
- // Non-emptying delete: the live DOM holds the edit until the keyup flush.
- expect(element.textContent).toBe('helloworld');
- });
-
- it('merges the next line up when deleting at the end of a line', () => {
- const { element } = setup('foo\nbar', { caretSelector: '.line' });
- placeSelection(element, 'foo'.length); // caret at end of `foo`
- const keyDown = dispatchDelete(element);
- expect(keyDown.defaultPrevented).toBe(true);
- expect(element.textContent).toBe('foobar');
- });
-
- it('deletes a non-collapsed selection forward', () => {
- const { element, onChange } = setup('hello world', { caretSelector: '.line' });
- placeSelection(element, 'hello'.length, ' world'.length); // select ` world`
- const keyDown = dispatchDelete(element);
- expect(keyDown.defaultPrevented).toBe(true);
- // A non-collapsed delete reconciles synchronously (reverts the live DOM for
- // React to re-render — guarding the frame-wrapper-removal crash), so assert
- // the engine's committed output rather than the reverted DOM.
- const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(text.replace(/\n$/, '')).toBe('hello');
- });
-
- it('flushes synchronously when the delete empties a line (no transient empty line)', () => {
- // Middle line is a single space; deleting it forward empties the line.
- const { element, onChange } = setup('hello\n \nworld', { caretSelector: '.line' });
- placeSelection(element, 'hello\n'.length); // caret at column 0 of the ` ` line
- const keyDown = dispatchDelete(element);
- expect(keyDown.defaultPrevented).toBe(true);
- // Synchronous flush reverts the live DOM for React to re-render, so assert
- // the engine's committed output (matching the Backspace-empty test above).
- const [text, position] = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(text).toBe('hello\n\nworld\n');
- expect(position.position).toBe('hello\n'.length); // caret stays on the now-empty line
- });
-
- it('is a no-op at the very end of the document', () => {
- const { element } = setup('hello', { caretSelector: '.line' });
- placeSelection(element, 'hello'.length); // caret at the end
- dispatchDelete(element);
- // Nothing to delete forward — the content is unchanged.
- expect(element.textContent).toBe('hello');
- });
- });
-
- // ---------------------------------------------------------------------------
- // minColumn option
- // ---------------------------------------------------------------------------
- describe('minColumn option', () => {
- function getCaretPosition(element: HTMLElement): number {
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- return pre.toString().length;
- }
-
- it('moves ArrowLeft at minColumn to end of previous line', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- // Caret at column 4 of line 1 (right after the indent, on the "w")
- placeSelection(element, 'hello\n '.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(true);
- expect(getCaretPosition(element)).toBe('hello'.length);
- });
-
- it('moves ArrowRight at end of line to minColumn of next line', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- // Caret at end of line 0
- placeSelection(element, 'hello'.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowRight',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(true);
- expect(getCaretPosition(element)).toBe('hello\n '.length);
- });
-
- it('does not intercept ArrowLeft when caret is past minColumn', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- // Caret at column 5 of line 1 (one char into "world")
- placeSelection(element, 'hello\n w'.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept ArrowRight when caret is not at end of line', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- // Caret in the middle of line 0
- placeSelection(element, 2);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowRight',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept ArrowRight when next line is not indented to minColumn', () => {
- const { element } = setup('hello\nhi', { minColumn: 4 });
- // Caret at end of line 0; next line "hi" has only 0 indent
- placeSelection(element, 'hello'.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowRight',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept ArrowLeft when current line indent is shorter than minColumn', () => {
- // Caret happens to be at column 4 but the line has non-whitespace within
- // the first 4 chars — this is not the "in the indent" case.
- const { element } = setup('hello\nabcdef', { minColumn: 4 });
- placeSelection(element, 'hello\nabcd'.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept arrow keys when shift is held (selection extension)', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- placeSelection(element, 'hello\n '.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- shiftKey: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept ArrowLeft on the first line', () => {
- const { element } = setup(' world', { minColumn: 4 });
- placeSelection(element, ' '.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does nothing when minColumn is undefined', () => {
- const { element } = setup('hello\n world');
- placeSelection(element, 'hello\n '.length);
-
- const event = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('snaps a click that lands inside the indent gutter to minColumn', () => {
- // The user clicks at column 1 of " world" — inside the clipped
- // 4-space gutter. The mouseup handler should jump the caret to
- // column 4 (the visible start of the line).
- const { element } = setup('hello\n world', { minColumn: 4 });
- placeSelection(element, 'hello\n '.length); // column 1 of line 1
-
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
-
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- expect(pre.toString().length).toBe('hello\n '.length);
- });
-
- it('does not snap a click that lands at or after minColumn', () => {
- const { element } = setup('hello\n world', { minColumn: 4 });
- placeSelection(element, 'hello\n wo'.length);
-
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
-
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- expect(pre.toString().length).toBe('hello\n wo'.length);
- });
-
- it('snaps the caret to minColumn when the editor receives focus in the gutter', async () => {
- // Tabbing into the editor lands the caret at column 0; after a frame
- // the focus handler should jump it to minColumn.
- const { element } = setup('hello\n world', { minColumn: 4 });
- placeSelection(element, 'hello\n'.length); // column 0 of line 1
-
- element.dispatchEvent(new FocusEvent('focus'));
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve(undefined));
- });
-
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- expect(pre.toString().length).toBe('hello\n '.length);
- });
-
- it('does not snap a non-collapsed selection that starts in the gutter', () => {
- // Drag selections shouldn't be clamped mid-gesture.
- const { element } = setup('hello\n world', { minColumn: 4 });
- const textNode = element.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, 'hello\n '.length);
- range.setEnd(textNode, 'hello\n wor'.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
-
- const after = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(after.startContainer, after.startOffset);
- expect(pre.toString().length).toBe('hello\n '.length);
- });
-
- it('Backspace at minColumn on a blank indented line clears the indent and keeps the caret on the line', () => {
- // Three lines: `hello`, a blank line of exactly minColumn (4)
- // whitespace characters, and `world`. With the caret at the end of
- // the blank line (column = minColumn), a single-character Backspace
- // would leave the caret in the clipped `[0, minColumn)` gutter
- // (invisible). Instead we clear the entire clipped indent so the line
- // becomes truly empty and the caret lands at its (visible) column 0 —
- // WITHOUT collapsing the line or jumping the caret to the previous one.
- const { element, onChange } = setup('hello\n \n world', {
- minColumn: 4,
- indentation: 2,
- });
- placeSelection(element, 'hello\n '.length);
-
- const keyDown = new KeyboardEvent('keydown', {
- key: 'Backspace',
- code: 'Backspace',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- expect(keyDown.defaultPrevented).toBe(true);
- // Emptying the line flushes synchronously (bypassing the async
- // re-highlight) so React commits the cleared blank line in the same tick
- // — there is no transient zero-height empty `.line` to flash. The flush
- // reverts the live DOM for React to re-render from `onChange`, so in this
- // static harness (mock `onChange`, no re-render) we verify the engine's
- // authoritative output: the committed text and caret position.
- const [text, position] = onChange.mock.calls[onChange.mock.calls.length - 1];
- // The blank line is now empty; the line itself is preserved.
- expect(text).toBe('hello\n\n world\n');
- // Caret stays on the (now empty) blank line, not the previous line.
- expect(position.position).toBe('hello\n'.length);
- });
-
- it('Backspace at minColumn on a non-blank indented line falls through to a single-character delete', () => {
- // The current line has more content past `minColumn`, so the
- // collapse-blank-line shortcut should not engage.
- const { element } = setup('hello\n world', { minColumn: 4, indentation: 2 });
- placeSelection(element, 'hello\n '.length);
-
- const keyDown = new KeyboardEvent('keydown', {
- key: 'Backspace',
- code: 'Backspace',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- expect(keyDown.defaultPrevented).toBe(true);
- // The fall-through path deletes a full `indentation` unit (2 chars)
- // when the pre-caret content is purely indent.
- expect(element.textContent).toBe('hello\n world');
- });
-
- it('Backspace at minColumn on a blank first line falls through (no previous line to land on)', () => {
- // No `position.line > 0` to use, so we keep the default behavior.
- const { element } = setup(' \nworld', { minColumn: 4, indentation: 2 });
- placeSelection(element, ' '.length);
-
- const keyDown = new KeyboardEvent('keydown', {
- key: 'Backspace',
- code: 'Backspace',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- expect(keyDown.defaultPrevented).toBe(true);
- expect(element.textContent).toBe(' \nworld');
- });
- });
-
- // ---------------------------------------------------------------------------
- // minRow / maxRow / onBoundary options
- // ---------------------------------------------------------------------------
- describe('visible row bounds', () => {
- function getCaretPosition(element: HTMLElement): number {
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- return pre.toString().length;
- }
-
- function dispatchKey(element: HTMLElement, key: string, modifiers: KeyboardEventInit = {}) {
- const event = new KeyboardEvent('keydown', {
- key,
- bubbles: true,
- cancelable: true,
- ...modifiers,
- });
- element.dispatchEvent(event);
- return event;
- }
-
- describe('ArrowUp at minRow', () => {
- it('invokes onBoundary and allows native caret movement', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
- // Caret at start of row 1 ("b")
- placeSelection(element, 'a\n'.length);
-
- const event = dispatchKey(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('does not invoke onBoundary on rows after minRow', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
- // Caret in row 2 ("c")
- placeSelection(element, 'a\nb\n'.length);
-
- const event = dispatchKey(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).not.toHaveBeenCalled();
- });
-
- it('does not invoke onBoundary when shift is held (selection)', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
- placeSelection(element, 'a\n'.length);
-
- const event = dispatchKey(element, 'ArrowUp', { shiftKey: true });
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).not.toHaveBeenCalled();
- });
-
- it('blocks when onBoundary is not provided', () => {
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2 });
- placeSelection(element, 'a\n'.length);
- const before = getCaretPosition(element);
-
- const event = dispatchKey(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(true);
- expect(getCaretPosition(element)).toBe(before);
- });
- });
-
- describe('ArrowDown at maxRow', () => {
- it('invokes onBoundary and allows native caret movement', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
- // Caret in row 2 ("c")
- placeSelection(element, 'a\nb\n'.length);
-
- const event = dispatchKey(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('does not invoke onBoundary on rows before maxRow', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
- placeSelection(element, 'a\n'.length);
-
- const event = dispatchKey(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).not.toHaveBeenCalled();
- });
-
- it('blocks when onBoundary is not provided', () => {
- const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2 });
- placeSelection(element, 'a\nb\n'.length);
-
- const event = dispatchKey(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(true);
- });
- });
-
- describe('ArrowLeft at start of minRow', () => {
- it('invokes onBoundary and allows native caret movement at column 0', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
- // Caret at column 0 of row 1
- placeSelection(element, 'a\n'.length);
-
- const event = dispatchKey(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('invokes onBoundary at minColumn on indented row', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\n bcd\ne', {
- minColumn: 4,
- minRow: 1,
- maxRow: 1,
- onBoundary,
- });
- // Caret at column minColumn (4) of row 1, lined up with "b"
- placeSelection(element, 'a\n '.length);
-
- const event = dispatchKey(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('blocks when onBoundary is not provided', () => {
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1 });
- placeSelection(element, 'a\n'.length);
-
- const event = dispatchKey(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(true);
- });
-
- it('does not invoke onBoundary mid-line on minRow', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
- // Caret in middle of row 1
- placeSelection(element, 'a\nb'.length);
-
- const event = dispatchKey(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).not.toHaveBeenCalled();
- });
- });
-
- describe('ArrowRight at end of maxRow', () => {
- it('invokes onBoundary and allows native caret movement at end of line', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
- // Caret at end of row 1
- placeSelection(element, 'a\nbcd'.length);
-
- const event = dispatchKey(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('blocks when onBoundary is not provided', () => {
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1 });
- placeSelection(element, 'a\nbcd'.length);
-
- const event = dispatchKey(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(true);
- });
-
- it('does not invoke onBoundary mid-line on maxRow', () => {
- const onBoundary = vi.fn();
- const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
- // Caret mid-row
- placeSelection(element, 'a\nb'.length);
-
- const event = dispatchKey(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).not.toHaveBeenCalled();
- });
-
- it('takes precedence over minColumn next-line jump', () => {
- const onBoundary = vi.fn();
- // maxRow == 1, next row indented to minColumn — boundary should win.
- const { element } = setup('a\nbcd\n e', {
- minColumn: 4,
- minRow: 1,
- maxRow: 1,
- onBoundary,
- });
- placeSelection(element, 'a\nbcd'.length);
-
- const event = dispatchKey(element, 'ArrowRight');
-
- // With onBoundary provided, native movement is allowed; the
- // useEditable-driven jump to minColumn of the next line is skipped.
- expect(event.defaultPrevented).toBe(false);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
- });
- });
-
- // ---------------------------------------------------------------------------
- // caretSelector option
- // ---------------------------------------------------------------------------
- describe('caretSelector option', () => {
- /**
- * Builds a `` whose internal HTML mirrors the highlighted output:
- * `.line` spans separated by literal `\n` text nodes. Returns a helper
- * that places the collapsed selection at the given total-text offset,
- * walking the actual `.line` text nodes (not the gap nodes) so the
- * caret ends up *inside* a matching element.
- */
- function setupLined(
- linesText: string[],
- opts: {
- caretSelector?: string;
- minRow?: number;
- maxRow?: number;
- minColumn?: number;
- onBoundary?: () => void;
- } = {},
- ) {
- const element = document.createElement('pre');
- linesText.forEach((text, idx) => {
- if (idx > 0) {
- element.appendChild(document.createTextNode('\n'));
- }
- const line = document.createElement('span');
- line.className = 'line';
- line.textContent = text;
- element.appendChild(line);
- });
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { unmount, rerender } = renderHook(
- (props) => useEditable(props.ref, props.onChange, props.opts),
- { initialProps: { ref, onChange, opts } },
- );
-
- function placeInLine(lineIndex: number, column: number) {
- const lineSpan = element.querySelectorAll('.line')[lineIndex];
- const textNode = lineSpan.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, column);
- range.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- return { element, placeInLine, unmount, rerender: () => rerender({ ref, onChange, opts }) };
- }
-
- function dispatchArrow(element: HTMLElement, key: string) {
- const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
- element.dispatchEvent(event);
- return event;
- }
-
- function caretOffset(element: HTMLElement) {
- const range = window.getSelection()!.getRangeAt(0);
- const pre = document.createRange();
- pre.setStart(element, 0);
- pre.setEnd(range.startContainer, range.startOffset);
- return pre.toString().length;
- }
-
- it('synchronously moves caret to end of previous line on ArrowLeft at column 0', () => {
- const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
- placeInLine(1, 0);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('hello'.length);
- });
-
- it('synchronously moves caret to start of next line on ArrowRight at end of line', () => {
- const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
- placeInLine(0, 'hello'.length);
-
- const event = dispatchArrow(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('hello\n'.length);
- });
-
- it('does not intercept ArrowLeft on the first line at column 0', () => {
- const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
- placeInLine(0, 0);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not intercept ArrowLeft mid-line', () => {
- const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
- placeInLine(1, 1);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('treats a blank intermediate line as a real next line for ArrowRight at end of line', () => {
- // Regression: the chunked text-node walker used to short-circuit
- // before recording that the next row exists when that row was
- // empty, causing ArrowRight at the end of `text` to no-op instead
- // of jumping into the spacer line. Documents like
- // `text` / `` / `text` are extremely common in code samples.
- const { element, placeInLine } = setupLined(['hello', '', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(0, 'hello'.length);
-
- const event = dispatchArrow(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('hello\n'.length);
- });
-
- it('treats a blank intermediate line as a real next line for ArrowLeft at column 0', () => {
- // Mirror of the above for the ArrowLeft gap-jump path: the caret
- // is on the line *after* a blank one, and pressing ArrowLeft at
- // column 0 should land at the end of the (zero-length) blank
- // line rather than no-op.
- const { element, placeInLine } = setupLined(['hello', '', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(2, 0);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('hello\n'.length);
- });
-
- it('does not intercept vertical arrows so wrapped visual lines stay native', () => {
- // ArrowUp/ArrowDown must remain unhijacked so browsers can navigate
- // wrapped visual lines in `pre-wrap` layouts. Gap nodes styled with
- // `line-height: 0` are skipped vertically by the browser anyway.
- const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
- placeInLine(0, 2);
-
- expect(dispatchArrow(element, 'ArrowDown').defaultPrevented).toBe(false);
- placeInLine(1, 2);
- expect(dispatchArrow(element, 'ArrowUp').defaultPrevented).toBe(false);
- });
-
- it('steps onto a zero-height blank line on ArrowUp instead of letting the browser skip it', () => {
- // A `.line` with no content renders at zero height (the gap newline is
- // `line-height: 0`), so native vertical navigation skips it. The hook
- // must move onto the blank line synchronously.
- const { element, placeInLine } = setupLined(['head', '', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(2, 2); // on 'world'
-
- const event = dispatchArrow(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('head\n'.length); // start of the blank row 1
- });
-
- it('lands on the nearest blank line when two blank lines stack (ArrowUp does not skip both)', () => {
- // Reproduces: "two empty lines, pressing up arrow skips both". One
- // ArrowUp must advance exactly one row — onto the second blank line —
- // not jump past both blanks to the non-empty line above.
- const { element, placeInLine } = setupLined(['head', '', '', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(3, 2); // on 'world'
-
- const event = dispatchArrow(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('head\n\n'.length); // row 2 (second blank), not row 0
- });
-
- it('steps onto a zero-height blank line on ArrowDown instead of skipping it', () => {
- const { element, placeInLine } = setupLined(['head', '', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(0, 2); // on 'head'
-
- const event = dispatchArrow(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('head\n'.length); // start of the blank row 1
- });
-
- it('keeps the caret where ArrowUp moved it after a boundary expand, not at the click position', () => {
- // Repro: after ArrowUp at the boundary expands the block, the caret
- // jumps back to where the user last clicked instead of staying where the
- // ArrowUp left it. Cause: arrow moves never refreshed `state.position`,
- // so the host re-render's caret restore replays the stale click position.
- const onBoundary = vi.fn();
- const { element, placeInLine, rerender } = setupLined(['head', 'world', 'tail'], {
- caretSelector: '.line',
- minRow: 1,
- onBoundary,
- });
-
- // 1. Click at line 2 ("tail") — seeds `state.position` via mouseup.
- placeInLine(2, 3);
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
-
- // 2. Caret is now on line 1 (the visible top / minRow), e.g. after the
- // user arrowed up — DOM caret moved but `state.position` is unchanged.
- placeInLine(1, 3);
-
- // 3. ArrowUp at minRow moves the caret onto line 0 and asks the host to
- // expand (onBoundary). The host re-renders; restore runs.
- const event = dispatchArrow(element, 'ArrowUp');
- expect(event.defaultPrevented).toBe(true);
- expect(onBoundary).toHaveBeenCalledTimes(1);
-
- // The expand re-render skips one restore (skipNextRestore); a later
- // re-render then restores `state.position`.
- rerender();
- rerender();
-
- // Caret should sit where ArrowUp left it (line 0, column 3 = "hea|d"),
- // NOT back at the click position on line 2.
- expect(caretOffset(element)).toBe('hea'.length);
- });
-
- it('does nothing when caretSelector is undefined', () => {
- const { element } = setup('hello\nworld');
- placeSelection(element, 'hello\n'.length);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('does not wrap when the caret is not inside a matching element', () => {
- // Plain-text editable: no `.line` spans exist, so the selector should
- // never match and the wrap should not fire even with caretSelector set.
- const { element } = setup('hello\nworld', { caretSelector: '.line' });
- placeSelection(element, 'hello\n'.length);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('synchronously moves caret to next line on ArrowDown at maxRow before invoking onBoundary', () => {
- // With `.line` spans separated by `\n` text-node gaps, native
- // ArrowDown at the visible end would drop the caret in the gap
- // between lines (the "between-lines" trap). The hook must move
- // the caret onto the next `.line` *first*, then notify the host
- // so the expansion happens with the caret already in place.
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['hello', 'world', 'tail'], {
- caretSelector: '.line',
- maxRow: 1,
- onBoundary,
- });
- placeInLine(1, 2);
-
- const event = dispatchArrow(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(true);
- // Caret column (2) preserved on the newly-targeted line.
- expect(caretOffset(element)).toBe('hello\nworld\nta'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('synchronously moves caret to next line on ArrowRight at end of maxRow before invoking onBoundary', () => {
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['hello', 'world', 'tail'], {
- caretSelector: '.line',
- maxRow: 1,
- onBoundary,
- });
- placeInLine(1, 'world'.length);
-
- const event = dispatchArrow(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(true);
- // Lands at column 0 of the next line, not in the inter-line gap.
- expect(caretOffset(element)).toBe('hello\nworld\n'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('treats a blank next line as a real line for ArrowDown at maxRow with caretSelector', () => {
- // Boundary-path coverage for the chunked-walker bug: when the row
- // immediately after `maxRow` is empty, ArrowDown must still cross
- // into it (preserving column, then invoking onBoundary) instead of
- // treating "blank line" as "no line" and no-op'ing.
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['hello', 'world', '', 'tail'], {
- caretSelector: '.line',
- maxRow: 1,
- onBoundary,
- });
- placeInLine(1, 2);
-
- const event = dispatchArrow(element, 'ArrowDown');
-
- expect(event.defaultPrevented).toBe(true);
- // Column 2 clamps to end of the blank line.
- expect(caretOffset(element)).toBe('hello\nworld\n'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('treats a blank next line as a real line for ArrowRight at end of maxRow with caretSelector', () => {
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['hello', 'world', '', 'tail'], {
- caretSelector: '.line',
- maxRow: 1,
- onBoundary,
- });
- placeInLine(1, 'world'.length);
-
- const event = dispatchArrow(element, 'ArrowRight');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('hello\nworld\n'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('synchronously moves caret to previous line on ArrowUp at minRow before invoking onBoundary', () => {
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['head', 'hello', 'world'], {
- caretSelector: '.line',
- minRow: 1,
- onBoundary,
- });
- placeInLine(1, 3);
-
- const event = dispatchArrow(element, 'ArrowUp');
-
- expect(event.defaultPrevented).toBe(true);
- // Column 3 clamped/preserved on previous line ('head'[3] = 'd' end).
- expect(caretOffset(element)).toBe('hea'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('synchronously moves caret to end of previous line on ArrowLeft at start of minRow before invoking onBoundary', () => {
- const onBoundary = vi.fn();
- const { element, placeInLine } = setupLined(['head', 'hello'], {
- caretSelector: '.line',
- minRow: 1,
- onBoundary,
- });
- placeInLine(1, 0);
-
- const event = dispatchArrow(element, 'ArrowLeft');
-
- expect(event.defaultPrevented).toBe(true);
- expect(caretOffset(element)).toBe('head'.length);
- expect(onBoundary).toHaveBeenCalledTimes(1);
- });
-
- it('snaps caret out of an inter-line gap text node after ArrowDown (post-keydown rAF snap)', async () => {
- // Simulate the browser's native ArrowDown behaviour landing the caret
- // in the literal `\n` text node between `.line` spans (which happens
- // when pressing Down on the last visible row of an expanded editable).
- // The handler captures the source column at keydown time and the rAF
- // snap should restore it on the destination line.
- const { element, placeInLine } = setupLined(['abcdef', 'world'], {
- caretSelector: '.line',
- });
- // Start at column 3 of "abcdef" — the column we want preserved.
- placeInLine(0, 3);
-
- // Dispatch ArrowDown. The handler reads the pre-move column (3)
- // synchronously before scheduling the rAF.
- dispatchArrow(element, 'ArrowDown');
-
- // Now simulate the browser's native default action dropping the caret
- // into the inter-line gap text node.
- const gapNode = element.childNodes[1];
- expect(gapNode.nodeType).toBe(Node.TEXT_NODE);
- const gapRange = document.createRange();
- gapRange.setStart(gapNode, 0);
- gapRange.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(gapRange);
-
- // Flush the rAF callback — the snap should run now.
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
-
- // Caret should be inside the next `.line` AT COLUMN 3.
- const after = window.getSelection()!.getRangeAt(0);
- const lineEl = (
- after.startContainer.nodeType === Node.ELEMENT_NODE
- ? (after.startContainer as Element)
- : after.startContainer.parentElement
- )?.closest('.line');
- expect(lineEl).not.toBeNull();
- expect(caretOffset(element)).toBe('abcdef\nwor'.length);
- });
-
- it('snaps caret out of an inter-line gap text node after ArrowUp (post-keydown rAF snap)', async () => {
- const { element, placeInLine } = setupLined(['abcdef', 'world'], {
- caretSelector: '.line',
- });
- // Start at column 4 of "world".
- placeInLine(1, 4);
-
- dispatchArrow(element, 'ArrowUp');
-
- // Simulate browser native dropping the caret in the gap.
- const gapNode = element.childNodes[1];
- const gapRange = document.createRange();
- gapRange.setStart(gapNode, 1);
- gapRange.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(gapRange);
-
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
-
- const after = window.getSelection()!.getRangeAt(0);
- const lineEl = (
- after.startContainer.nodeType === Node.ELEMENT_NODE
- ? (after.startContainer as Element)
- : after.startContainer.parentElement
- )?.closest('.line');
- expect(lineEl).not.toBeNull();
- // Snapped to column 4 of the previous line ("abcdef" → "abcd|ef").
- expect(caretOffset(element)).toBe('abcd'.length);
- });
-
- it('clamps the preserved column to the destination line length on ArrowDown', async () => {
- const { element, placeInLine } = setupLined(['abcdefghij', 'short'], {
- caretSelector: '.line',
- });
- // Start at column 8 — longer than the destination line "short" (5 chars).
- placeInLine(0, 8);
-
- dispatchArrow(element, 'ArrowDown');
-
- const gapNode = element.childNodes[1];
- const gapRange = document.createRange();
- gapRange.setStart(gapNode, 0);
- gapRange.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(gapRange);
-
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
-
- // Column clamped to end of "short".
- expect(caretOffset(element)).toBe('abcdefghij\nshort'.length);
- });
-
- it('snaps back to the last line when ArrowDown lands past it', async () => {
- // ArrowDown on the last visible row can drop the caret into trailing
- // whitespace *after* the final `.line` (no next line to forward to).
- // The snap should then go back to the last line, preserving column.
- const { element, placeInLine } = setupLined(['hello', 'wonderful'], {
- caretSelector: '.line',
- });
- placeInLine(1, 4);
-
- dispatchArrow(element, 'ArrowDown');
-
- // Simulate browser dropping the caret in a trailing text node past
- // the last `.line`. Append a synthetic trailing text node to mimic
- // what real browsers do when they overshoot.
- const trailing = document.createTextNode('\n');
- element.appendChild(trailing);
- const trailingRange = document.createRange();
- trailingRange.setStart(trailing, 0);
- trailingRange.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(trailingRange);
-
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
-
- const after = window.getSelection()!.getRangeAt(0);
- const lineEl = (
- after.startContainer.nodeType === Node.ELEMENT_NODE
- ? (after.startContainer as Element)
- : after.startContainer.parentElement
- )?.closest('.line');
- expect(lineEl).not.toBeNull();
- // Snapped back to column 4 of the last line ("wond|erful").
- expect(caretOffset(element)).toBe('hello\nwond'.length);
- });
-
- it('snaps forward to the first line when ArrowUp lands before it', async () => {
- const { element, placeInLine } = setupLined(['hello', 'world'], {
- caretSelector: '.line',
- });
- placeInLine(0, 3);
-
- dispatchArrow(element, 'ArrowUp');
-
- // Simulate browser dropping the caret in a synthetic leading text node.
- const leading = document.createTextNode('\n');
- element.insertBefore(leading, element.firstChild);
- const leadingRange = document.createRange();
- leadingRange.setStart(leading, 0);
- leadingRange.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(leadingRange);
-
- await new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
-
- const after = window.getSelection()!.getRangeAt(0);
- const lineEl = (
- after.startContainer.nodeType === Node.ELEMENT_NODE
- ? (after.startContainer as Element)
- : after.startContainer.parentElement
- )?.closest('.line');
- expect(lineEl).not.toBeNull();
- // Snapped forward to column 3 of the first line ("hel|lo").
- expect(caretOffset(element)).toBe('\nhel'.length);
- });
-
- it('snaps the caret onto the next line when a click lands in an inter-line gap node', () => {
- // Clicking between `.line` spans places the caret in the literal
- // `\n` gap text node, which is not selectable from the user's POV.
- // The mouseup handler should snap forward onto the next line so
- // typing immediately works as expected.
- const { element } = setupLined(['hello', 'world'], { caretSelector: '.line' });
-
- // Place caret in the gap text node between lines 0 and 1.
- const gapNode = element.childNodes[1];
- expect(gapNode.nodeType).toBe(Node.TEXT_NODE);
- const range = document.createRange();
- range.setStart(gapNode, 0);
- range.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
-
- const after = window.getSelection()!.getRangeAt(0);
- const lineEl = (
- after.startContainer.nodeType === Node.ELEMENT_NODE
- ? (after.startContainer as Element)
- : after.startContainer.parentElement
- )?.closest('.line');
- expect(lineEl).not.toBeNull();
- // Caret lands at the start of the next line ("|world").
- expect(caretOffset(element)).toBe('hello\n'.length);
- });
- });
-
- // ---------------------------------------------------------------------------
- // Undo/Redo
- // ---------------------------------------------------------------------------
- describe('undo/redo', () => {
- it('handles Ctrl+Z (undo key detection)', () => {
- const { element } = setup('hello');
- placeSelection(element, 0);
-
- const event = new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- // Event should be prevented (undo is handled internally)
- expect(event.defaultPrevented).toBe(true);
- });
-
- it('handles Meta+Z (undo key detection for Mac)', () => {
- const { element } = setup('hello');
- placeSelection(element, 0);
-
- const event = new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- metaKey: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- expect(event.defaultPrevented).toBe(true);
- });
-
- it('does not treat Ctrl+Alt+Z as undo', () => {
- const { element } = setup('hello');
- placeSelection(element, 0);
-
- const event = new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- altKey: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(event);
-
- // Alt key present, so not an undo shortcut
- expect(event.defaultPrevented).toBe(false);
- });
-
- it('can undo all the way back to the original content before any edits', () => {
- // Regression: trackState() guarded on !state.position, which is only set by
- // flushChanges() (first keyup). So the state before the very first edit was
- // never pushed into history. Undo could only go back to after-the-first-edit,
- // not to the original content.
- //
- // Use fallback mode (contentEditable='true') so edit.insert() is called
- // synchronously from keydown, giving MutationObserver a real DOM mutation
- // to process and making flushChanges() call onChange on keyup.
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- // Place cursor at end of 'hello' — this gives trackState() a live selection
- // to record from on the very first keydown (before any flushChanges has run).
- placeSelection(element, 5);
-
- // Type 'a'. In fallback mode the keydown handler calls edit.insert('a')
- // which mutates the DOM; flushChanges on keyup then calls onChange('helloa\n').
- const keyDown = new KeyboardEvent('keydown', {
- key: 'a',
- code: 'KeyA',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
- const keyUp = new KeyboardEvent('keyup', {
- key: 'a',
- code: 'KeyA',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyUp);
-
- // Verify the edit was reported
- expect(onChange).toHaveBeenCalledWith('helloa\n', expect.any(Object));
-
- // Simulate the re-render that would happen in a real app after onChange fires.
- // This resets state.disconnected (set to true by flushChanges) back to false
- // so the next keydown can process normally rather than hitting the disconnected guard.
- rerender();
-
- // Undo (Ctrl+Z) — should restore the original 'hello\n', not stay at 'helloa\n'
- const undoKey = new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(undoKey);
-
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe('hello\n');
- });
-
- it('undo is not a no-op after two Enter keypresses within 500ms', () => {
- // Regression: the 500ms timestamp dedup in trackState() blocked recording a
- // new history checkpoint on the keyup after the second Enter. historyAt was
- // left pointing at the initial entry (index 0), so Ctrl+Z tried to go to
- // history[-1], found nothing, reset to 0, and never called onChange — undo
- // silently did nothing.
- //
- // Fix: trackState(ignoreTimestamp=true) is called on keyup for Enter so each
- // Enter always creates its own undo checkpoint regardless of timing.
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- // Cursor in the middle of 'hello' so Enter produces a content change
- // that differs from the original toString('hello') = 'hello\n'.
- placeSelection(element, 3);
-
- // First Enter
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'Enter', bubbles: true, cancelable: true }),
- );
- rerender(); // Simulate React re-render resetting state.disconnected
-
- // Restore cursor after flushChanges reverted the DOM
- placeSelection(element, 3);
-
- // Second Enter (within 500ms of the first — triggers the 500ms dedup bug)
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'Enter', bubbles: true, cancelable: true }),
- );
- rerender();
-
- const callsBefore = onChange.mock.calls.length;
-
- // Ctrl+Z — must not be a silent no-op
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore);
- // Restores the content before the first Enter
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe('hello\n');
- });
-
- it('after an external content swap, Ctrl+Z restores the user-typed content exactly', () => {
- // Regression: when a host swaps the editable's content from outside the
- // keystroke pipeline (e.g. a `Reset` button calling `setSource`), the
- // user's prior edits must still be reachable via Ctrl+Z. The undo stack
- // should record the swapped content as a new checkpoint so undo lands
- // back on what the user typed, byte-for-byte.
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- placeSelection(element, 5);
-
- // Type 'a' so the user has something to undo back to.
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
-
- expect(onChange).toHaveBeenLastCalledWith('helloa\n', expect.any(Object));
- // flushChanges reverts the DOM mutations after firing onChange — in a
- // real React host the controlled re-render would patch the DOM back
- // to the post-edit text. Mirror that here so the next external swap
- // is a real mutation the observer can pick up.
- element.textContent = 'helloa';
- rerender();
-
- // Externally swap the editable's content — simulates the host
- // resetting the controlled source. React would normally replace the
- // text node here; assigning textContent is the simplest stand-in.
- element.textContent = 'hello';
- rerender();
-
- const callsBefore = onChange.mock.calls.length;
-
- // Ctrl+Z — must restore exactly the user-typed content, not stay
- // on the externally-swapped content.
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore);
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe('helloa\n');
- });
-
- it('after an external content swap, redo (Ctrl+Shift+Z) returns to the swapped content', () => {
- // After undoing back to the user-typed content, redoing should restore
- // the externally-swapped content — i.e. the swap is treated as a real
- // undo checkpoint, not a black hole that swallows future redo steps.
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- placeSelection(element, 5);
-
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
- // Mirror React's post-commit DOM patch (see the previous test).
- element.textContent = 'helloa';
- rerender();
-
- element.textContent = 'hello';
- rerender();
-
- // Ctrl+Z — undo back to user-typed content.
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- }),
- );
- expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe('helloa\n');
-
- // Simulate the host re-rendering after onChange — restores the
- // observer connection so the next key event isn't gated on stale
- // disconnected state.
- rerender();
-
- // Ctrl+Shift+Z — redo to the swapped content.
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- shiftKey: true,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe('hello\n');
- });
-
- it('after external swap, Ctrl+Z steps through every recorded edit, not just the latest', () => {
- // The most recent *user-typed* checkpoint should sit one undo step
- // below the swap, but additional checkpoints made before the swap must
- // remain reachable too — undo should walk the whole history.
- vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
- try {
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- const typeChar = (key: string) => {
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key,
- code: `Key${key.toUpperCase()}`,
- bubbles: true,
- cancelable: true,
- }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', {
- key,
- code: `Key${key.toUpperCase()}`,
- bubbles: true,
- cancelable: true,
- }),
- );
- };
-
- // First batch — 'a'.
- placeSelection(element, 5);
- typeChar('a');
- // flushChanges reverts the DOM mutations after firing onChange — the
- // host is supposed to re-render with the new content. Mirror that
- // here so the next batch sees the post-edit text.
- element.textContent = 'helloa';
- rerender();
-
- // Wait past the 500ms dedup so the next batch creates its own
- // history checkpoint instead of being coalesced with the first.
- vi.advanceTimersByTime(600);
-
- // Second batch — 'b'.
- placeSelection(element, element.textContent!.length);
- typeChar('b');
- element.textContent = 'helloab';
- rerender();
-
- // External swap (host reset).
- element.textContent = 'hello';
- rerender();
-
- const undo = () =>
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'z',
- code: 'KeyZ',
- ctrlKey: true,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- // Step 1 — back to 'helloab'.
- undo();
- expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe('helloab\n');
- rerender();
-
- // Step 2 — back to 'helloa'.
- undo();
- expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe('helloa\n');
- rerender();
-
- // Step 3 — back to the original 'hello'.
- undo();
- expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toBe('hello\n');
- } finally {
- vi.useRealTimers();
- }
- });
-
- it('does not revert host reconciliation mutations on the next keystroke', () => {
- // Regression: an earlier optimization drained the MutationObserver's
- // pending records into `state.queue` from the layout-effect cleanup,
- // hoping to detect external content swaps for sale on a later render.
- // That broke the editor visually because React's own reconciliation
- // between renders also produces MutationRecords; pushing them into
- // `state.queue` meant the next keystroke's `commit()` reverted React's
- // DOM patches alongside the user's edit. The fix keeps the dirty
- // signal as a boolean (drains records, then disconnects) so React's
- // reconciliation never leaks into the revert pipeline.
- //
- // This test reproduces the bug pattern: simulate React replacing a
- // child node between renders (producing childList records the
- // observer sees), then type a character. The typed character must
- // land on top of the reconciled DOM, leaving the reconciled child in
- // place rather than reverting it.
- const element = document.createElement('pre');
- const span = document.createElement('span');
- span.textContent = 'hello';
- element.appendChild(span);
- document.body.appendChild(element);
-
- let contentEditableValue = 'true';
- Object.defineProperty(element, 'contentEditable', {
- get() {
- return contentEditableValue;
- },
- set(value: string) {
- if (value === 'plaintext-only') {
- throw new DOMException(
- "Failed to set 'contentEditable': 'plaintext-only' is not supported",
- );
- }
- contentEditableValue = value;
- },
- configurable: true,
- });
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { rerender } = renderHook(() => useEditable(ref, onChange));
-
- placeSelection(element, 5);
-
- // Type 'a' so we have an established `lastCommittedContent` and the
- // reconciliation that follows is treated as the host re-rendering
- // with the new content.
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'a', code: 'KeyA', bubbles: true, cancelable: true }),
- );
-
- expect(onChange).toHaveBeenLastCalledWith('helloa\n', expect.any(Object));
-
- // Rerender first so the layout effect reconnects the observer
- // (commit() set `state.disconnected = true`). The reconciliation
- // mutation we're about to make has to be observed.
- rerender();
-
- // Simulate React's reconciliation: a real host would replace the
- // span with a freshly-rendered node tree carrying the post-edit
- // text. The replacement produces childList MutationRecords that
- // the now-connected observer picks up.
- const replacement = document.createElement('span');
- replacement.textContent = 'helloa';
- element.replaceChild(replacement, span);
-
- // Rerender again — the layout-effect cleanup must drain those
- // records into the dirty bit (NOT into `state.queue`) before
- // `disconnect()` drops them. If the records leak into `state.queue`,
- // the next keystroke's `commit()` will revert React's reconciliation.
- rerender();
-
- // Place the caret at the end of the reconciled content and type 'b'.
- placeSelection(element, 6);
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'b', code: 'KeyB', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'b', code: 'KeyB', bubbles: true, cancelable: true }),
- );
-
- // The reconciled must still be in place after `commit()` runs
- // its revert phase — if the bug regressed, the original `span` would
- // be reinserted (and `replacement` removed).
- expect(element.contains(replacement)).toBe(true);
- expect(element.contains(span)).toBe(false);
-
- // And onChange must report the typed-on-top-of-reconciliation
- // content, not a reverted-then-edited variant.
- expect(onChange).toHaveBeenLastCalledWith('helloab\n', expect.any(Object));
- });
- });
-
- // ---------------------------------------------------------------------------
- // Paste
- // ---------------------------------------------------------------------------
- describe('paste', () => {
- it('handles paste events', () => {
- const { element } = setup('hello');
- placeSelection(element, 5);
-
- const clipboardData = {
- getData: vi.fn().mockReturnValue(' world'),
- };
-
- const event = new Event('paste', { bubbles: true, cancelable: true }) as any;
- event.clipboardData = clipboardData;
- event.preventDefault = vi.fn();
- element.dispatchEvent(event);
-
- expect(event.preventDefault).toHaveBeenCalled();
- expect(clipboardData.getData).toHaveBeenCalledWith('text/plain');
- });
- });
-
- // ---------------------------------------------------------------------------
- // Copy / Cut
- // ---------------------------------------------------------------------------
- describe('copy/cut', () => {
- /**
- * Builds a `` mirroring the highlighter output: `display: block`
- * `.line` spans separated by literal `\n` text node siblings. Without
- * the copy override, copying a multi-line selection on this DOM
- * produces duplicated newlines (one from each block element + the
- * explicit gap text node).
- */
- function setupLined(linesText: string[]) {
- const element = document.createElement('pre');
- linesText.forEach((text, idx) => {
- if (idx > 0) {
- element.appendChild(document.createTextNode('\n'));
- }
- const line = document.createElement('span');
- line.className = 'line';
- // Mark as block so range.toString() still produces the canonical
- // text — this also documents the layout being defended against.
- line.style.display = 'block';
- line.textContent = text;
- element.appendChild(line);
- });
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- const { unmount } = renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- function selectAcrossLines() {
- const lineSpans = element.querySelectorAll('.line');
- const startText = lineSpans[0].firstChild!;
- const endText = lineSpans[lineSpans.length - 1].firstChild!;
- const range = document.createRange();
- range.setStart(startText, 0);
- range.setEnd(endText, endText.textContent!.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- return { element, selectAcrossLines, onChange, unmount };
- }
-
- function dispatchClipboardEvent(element: HTMLElement, type: 'copy' | 'cut') {
- const setData = vi.fn();
- const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
- return { event, setData };
- }
-
- it('writes the canonical text to the clipboard on copy without duplicate newlines', () => {
- const { element, selectAcrossLines } = setupLined(['hello', 'world']);
- selectAcrossLines();
-
- const { event, setData } = dispatchClipboardEvent(element, 'copy');
-
- expect(event.defaultPrevented).toBe(true);
- expect(setData).toHaveBeenCalledWith('text/plain', 'hello\nworld');
- });
-
- it('also writes the serialized HTML fragment so rich-text paste keeps highlighting', () => {
- const { element, selectAcrossLines } = setupLined(['hello', 'world']);
- selectAcrossLines();
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- expect(htmlCall).toBeDefined();
- const html = htmlCall![1];
- // Both `.line` wrappers and the literal newline gap node round-trip.
- expect(html).toContain('class="line"');
- expect(html).toContain('hello');
- expect(html).toContain('world');
- // Wrapper is a `` so monospace + whitespace context survives.
- expect(html.startsWith(' {
- // Consumers scope styles by class on the editable ``; keep
- // that class on the clipboard wrapper so paste targets that load
- // the same stylesheet still match.
- const element = document.createElement('pre');
- element.className = 'code-block hljs-language-tsx';
- ['hello', 'world'].forEach((text, idx) => {
- if (idx > 0) {
- element.appendChild(document.createTextNode('\n'));
- }
- const lineSpan = document.createElement('span');
- lineSpan.className = 'line';
- lineSpan.style.display = 'block';
- lineSpan.textContent = text;
- element.appendChild(lineSpan);
- });
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- const lineSpans = element.querySelectorAll('.line');
- const startText = lineSpans[0].firstChild!;
- const endText = lineSpans[lineSpans.length - 1].firstChild!;
- const range = document.createRange();
- range.setStart(startText, 0);
- range.setEnd(endText, endText.textContent!.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- expect(html).toContain('class="code-block hljs-language-tsx"');
- });
-
- it('inlines the editable background color and adds rounded padding to the wrapper', () => {
- // Paste targets that do not load the editable's stylesheet should
- // still render with a card-like background + rounded corners that
- // match the source visual.
- const element = document.createElement('pre');
- element.style.backgroundColor = 'rgb(13, 17, 23)';
- ['hello', 'world'].forEach((text, idx) => {
- if (idx > 0) {
- element.appendChild(document.createTextNode('\n'));
- }
- const lineSpan = document.createElement('span');
- lineSpan.className = 'line';
- lineSpan.style.display = 'block';
- lineSpan.textContent = text;
- element.appendChild(lineSpan);
- });
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- const lineSpans = element.querySelectorAll('.line');
- const startText = lineSpans[0].firstChild!;
- const endText = lineSpans[lineSpans.length - 1].firstChild!;
- const range = document.createRange();
- range.setStart(startText, 0);
- range.setEnd(endText, endText.textContent!.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
-
- expect(html).toContain('background-color:rgb(13, 17, 23)');
- expect(html).toContain('padding:1em');
- expect(html).toContain('border-radius:0.5em');
- });
-
- it('inlines computed styles so external paste targets keep highlighting without our CSS', () => {
- const element = document.createElement('pre');
- const line = document.createElement('span');
- line.className = 'line';
- const token = document.createElement('span');
- token.className = 'pl-k';
- // Inline style so jsdom's getComputedStyle returns it.
- token.style.color = 'rgb(255, 0, 0)';
- token.style.fontWeight = 'bold';
- token.textContent = 'const';
- line.appendChild(token);
- element.appendChild(line);
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- const range = document.createRange();
- range.selectNode(token);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const setData = vi.fn();
- const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- expect(html).toContain('color:rgb(255, 0, 0)');
- expect(html).toContain('font-weight:bold');
- });
-
- it('preserves the styled wrapper when only part of a single token is selected', () => {
- // `Range.cloneContents` returns a bare text node when the selection
- // is entirely inside a single text node, dropping the surrounding
- // span. Without ancestor reconstruction the partial token would
- // serialize as `ons
` and lose its highlight class.
- const element = document.createElement('pre');
- const line = document.createElement('span');
- line.className = 'line';
- const token = document.createElement('span');
- token.className = 'pl-k';
- token.style.color = 'rgb(255, 0, 0)';
- token.style.fontWeight = 'bold';
- token.textContent = 'consts';
- line.appendChild(token);
- element.appendChild(line);
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- // Select "ons" — entirely inside the token's text node.
- const textNode = token.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, 1);
- range.setEnd(textNode, 4);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const setData = vi.fn();
- const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- // The wrapping token span (with its highlight class) is preserved
- // and styled, and the partial text content sits inside it.
- expect(html).toContain('class="pl-k"');
- expect(html).toContain('color:rgb(255, 0, 0)');
- expect(html).toContain('font-weight:bold');
- expect(html).toContain('>ons<');
- // The intermediate `.line` ancestor is also reconstructed so the
- // block-level layout context survives.
- expect(html).toContain('class="line"');
- });
-
- it('preserves the styled wrapper when the selection spans multiple children of a token', () => {
- // Highlighted strings are typically rendered as
- // 'react'
- // Selecting from inside the opening quote across to inside the
- // closing quote leaves `commonAncestorContainer` on `.pl-s`, which
- // `Range.cloneContents` would drop — losing the outer string-token
- // styling for every paste target.
- const element = document.createElement('pre');
- const line = document.createElement('span');
- line.className = 'line';
- const stringToken = document.createElement('span');
- stringToken.className = 'pl-s';
- stringToken.style.color = 'rgb(3, 47, 98)';
- const openQuote = document.createElement('span');
- openQuote.className = 'pl-pds';
- openQuote.textContent = "'";
- const middle = document.createTextNode('react');
- const closeQuote = document.createElement('span');
- closeQuote.className = 'pl-pds';
- closeQuote.textContent = "'";
- stringToken.append(openQuote, middle, closeQuote);
- line.appendChild(stringToken);
- element.appendChild(line);
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- // Select from inside the opening quote to inside the closing quote
- // — the common ancestor is the `.pl-s` element.
- const range = document.createRange();
- range.setStart(openQuote.firstChild!, 0);
- range.setEnd(closeQuote.firstChild!, 1);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const setData = vi.fn();
- const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- // The outer string-token wrapper is reconstructed and styled so
- // the middle text inherits the token-level color in paste targets.
- expect(html).toContain('class="pl-s"');
- expect(html).toContain('color:rgb(3, 47, 98)');
- // The inner punctuation wrappers also survive on each side of the
- // middle text.
- expect(html).toContain('class="pl-pds"');
- expect(html).toContain('react');
- });
-
- it('aligns style inlining when the common ancestor is the line wrapper', () => {
- // When the selection spans multiple sibling tokens inside one
- // `.line`, the common ancestor is `.line`. The style-inlining
- // walks must stay aligned: the keyword token's color should land
- // on the keyword clone, not on the reconstructed `.line` wrapper
- // or on a later sibling.
- const element = document.createElement('pre');
- const line = document.createElement('span');
- line.className = 'line';
- line.style.display = 'block';
- const keyword = document.createElement('span');
- keyword.className = 'pl-k';
- keyword.style.color = 'rgb(215, 58, 73)';
- keyword.textContent = 'const';
- const space = document.createTextNode(' ');
- const ident = document.createElement('span');
- ident.className = 'pl-c1';
- ident.style.color = 'rgb(0, 92, 197)';
- ident.textContent = 'foo';
- line.append(keyword, space, ident);
- element.appendChild(line);
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange), {
- initialProps: { ref, onChange },
- });
-
- // Select from inside the keyword across the space into the ident.
- const range = document.createRange();
- range.setStart(keyword.firstChild!, 2);
- range.setEnd(ident.firstChild!, 2);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const setData = vi.fn();
- const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- // The reconstructed `.line` wrapper must NOT inherit a token color
- // — it should only carry its own styles (display:block here).
- const lineMatch = html.match(/]*style="([^"]*)"/);
- expect(lineMatch).not.toBeNull();
- expect(lineMatch![1]).not.toContain('rgb(215, 58, 73)');
- expect(lineMatch![1]).not.toContain('rgb(0, 92, 197)');
- // Each token clone keeps its own color on its own element.
- expect(html).toMatch(/class="pl-k"[^>]*style="[^"]*color:rgb\(215, 58, 73\)/);
- expect(html).toMatch(/class="pl-c1"[^>]*style="[^"]*color:rgb\(0, 92, 197\)/);
- });
-
- it('writes canonical text and clears the selection on cut', () => {
- const { element, selectAcrossLines, onChange } = setupLined(['hello', 'world']);
- selectAcrossLines();
-
- const { event, setData } = dispatchClipboardEvent(element, 'cut');
-
- expect(event.defaultPrevented).toBe(true);
- expect(setData).toHaveBeenCalledWith('text/plain', 'hello\nworld');
- // Cut should empty the selected range, leaving just the trailing \n.
- expect(onChange).toHaveBeenCalled();
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe('\n');
- });
-
- it('does not intercept when the selection is collapsed', () => {
- const { element } = setupLined(['hello', 'world']);
- const lineSpan = element.querySelector('.line')!;
- const range = document.createRange();
- range.setStart(lineSpan.firstChild!, 2);
- range.collapse(true);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { event, setData } = dispatchClipboardEvent(element, 'copy');
-
- expect(event.defaultPrevented).toBe(false);
- expect(setData).not.toHaveBeenCalled();
- });
-
- it('does not intercept when the selection is outside the editable', () => {
- const { element } = setupLined(['hello', 'world']);
- const outside = document.createElement('div');
- outside.textContent = 'other';
- document.body.appendChild(outside);
- const range = document.createRange();
- range.selectNodeContents(outside);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { event, setData } = dispatchClipboardEvent(element, 'copy');
-
- expect(event.defaultPrevented).toBe(false);
- expect(setData).not.toHaveBeenCalled();
- });
-
- it('strips up to minColumn leading whitespace per line from text/plain', () => {
- const { element } = setup(' hello\n world\n short', { minColumn: 4 });
- const range = document.createRange();
- range.selectNodeContents(element);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
-
- const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
- // Lines 1-2 lose all 4 leading spaces; line 3 has only 2 to strip.
- expect(plainCall![1]).toBe('hello\nworld\nshort');
- });
-
- it('strips up to minColumn leading whitespace per line from text/html', () => {
- const element = document.createElement('pre');
- const lineA = document.createElement('span');
- lineA.className = 'line';
- lineA.style.display = 'block';
- lineA.textContent = ' hello';
- const lineB = document.createElement('span');
- lineB.className = 'line';
- lineB.style.display = 'block';
- lineB.textContent = ' world';
- element.appendChild(lineA);
- element.appendChild(document.createTextNode('\n'));
- element.appendChild(lineB);
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
- renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
- initialProps: { ref, onChange, opts: { minColumn: 4 } },
- });
-
- const range = document.createRange();
- range.setStart(lineA.firstChild!, 0);
- range.setEnd(lineB.firstChild!, lineB.firstChild!.textContent!.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const setData = vi.fn();
- const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
- clipboardData: { setData: typeof setData };
- };
- event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
- element.dispatchEvent(event);
-
- const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
- const html = htmlCall![1] as string;
- // Leading 4-space indent removed from each `.line`'s text content.
- expect(html).not.toContain(' hello');
- expect(html).not.toContain(' world');
- expect(html).toContain('hello');
- expect(html).toContain('world');
- });
-
- it('only strips the remaining gutter portion when the selection starts mid-gutter', () => {
- // 6 spaces of indent + content, minColumn=4. User selects starting
- // from column 2 — they grabbed 2 of the 4 gutter spaces explicitly
- // plus 2 real-indent spaces. Only the remaining 2 gutter spaces
- // (minColumn - startColumn = 4 - 2) should be stripped, preserving
- // the 2 real-indent spaces in the captured text.
- const { element } = setup(' hello\n world', { minColumn: 4 });
- const textNode = element.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, 2);
- range.setEnd(textNode, ' hello\n world'.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
-
- const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
- // First line: 4 captured spaces - 2 stripped = 2 spaces kept + "hello".
- // Second line: starts at column 0 of the document, so full 4-space
- // gutter is stripped, leaving 2 real-indent spaces + "world".
- expect(plainCall![1]).toBe(' hello\n world');
- });
-
- it('strips nothing on the first line when the selection starts past the gutter', () => {
- // minColumn=4 but selection starts at column 4 — no gutter is
- // captured for the first line, so no stripping should occur there.
- const { element } = setup(' hello\n world', { minColumn: 4 });
- const textNode = element.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, 4);
- range.setEnd(textNode, ' hello\n world'.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'copy');
-
- const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
- expect(plainCall![1]).toBe(' hello\n world');
- });
-
- it('keeps the gutter whitespace in the document when cut starts inside the gutter', () => {
- // minColumn=4 — first 4 chars of each line are clipped indent
- // gutter. A drag-cut starting at column 2 of line 1 must not
- // delete the unselected/unpublished gutter chars from the
- // document: cut should be lossless against the clipboard.
- const { element, onChange } = setup(' hello\n world', { minColumn: 4 });
- const textNode = element.firstChild!;
- const range = document.createRange();
- range.setStart(textNode, 2);
- range.setEnd(textNode, ' hello\n world'.length);
- const selection = window.getSelection()!;
- selection.removeAllRanges();
- selection.addRange(range);
-
- const { setData } = dispatchClipboardEvent(element, 'cut');
-
- // Clipboard payload omits the gutter (matches what the user saw).
- const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
- expect(plainCall![1]).toBe(' hello\n world');
-
- // The document keeps the stripped gutter chars at the cut location:
- // the 2 unselected leading chars + the 2 stripped gutter chars
- // restored = 4 spaces on line 1, then \n + 4 stripped gutter
- // spaces on line 2, then a trailing newline.
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- expect(lastCall[0]).toBe(' \n \n');
- });
- });
-
- // ---------------------------------------------------------------------------
- // preParse option
- // ---------------------------------------------------------------------------
- describe('preParse option', () => {
- /**
- * Mounts `useEditable` with a `preParse` callback. Returns the same
- * helpers as `setup` plus the `preParse` mock.
- */
- function setupWithPreParse(
- initialContent: string,
- preParse: (text: string, position: Position, signal: AbortSignal) => Promise,
- ) {
- const element = document.createElement('pre');
- element.textContent = initialContent;
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position, preParsed?: unknown) => void>();
-
- const { result, rerender, unmount } = renderHook(
- (props: { tick: number }) => {
- // `tick` is read so each rerender re-invokes the hook and its
- // useLayoutEffect re-attaches the MutationObserver after a
- // flushChanges() disconnect.
- void props.tick;
- return useEditable(ref, onChange, { preParse });
- },
- { initialProps: { tick: 0 } },
- );
- placeSelection(element, 0);
-
- let tick = 0;
- const reattach = () => {
- tick += 1;
- rerender({ tick });
- };
-
- return { element, ref, onChange, result, reattach, unmount };
- }
-
- /**
- * Simulate a single character typed into `element` at the end of the
- * current text. Mutates the DOM synchronously (so the MutationObserver
- * picks it up) and dispatches a keyup so `flushChanges` runs.
- */
- function typeChar(element: HTMLElement, character: string) {
- const text = (element.textContent ?? '') + character;
- element.textContent = text;
- placeSelection(element, text.length);
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: character, bubbles: true, cancelable: true }),
- );
- }
-
- it('awaits preParse before firing onChange and forwards its result', async () => {
- let resolvePreParse: ((value: unknown) => void) | undefined;
- const preParseResult = { type: 'root', children: [] };
- const preParse = vi.fn(
- () =>
- new Promise((resolve) => {
- resolvePreParse = resolve;
- }),
- );
-
- const { element, onChange } = setupWithPreParse('hello', preParse);
-
- typeChar(element, 'a');
-
- // onChange must NOT have fired yet — preParse is still pending
- expect(preParse).toHaveBeenCalledTimes(1);
- expect(onChange).not.toHaveBeenCalled();
-
- resolvePreParse!(preParseResult);
- await Promise.resolve();
- await Promise.resolve();
-
- expect(onChange).toHaveBeenCalledTimes(1);
- const [text, , forwarded] = onChange.mock.calls[0];
- expect(text).toBe('helloa\n');
- expect(forwarded).toBe(preParseResult);
- });
-
- it('aborts the prior preParse when a newer keystroke flushes', async () => {
- const signals: AbortSignal[] = [];
- let resolveSecond: ((value: unknown) => void) | undefined;
- let callCount = 0;
- const preParse = vi.fn((_text: string, _pos: Position, signal: AbortSignal) => {
- signals.push(signal);
- callCount += 1;
- if (callCount === 1) {
- // Never resolve — the second flush should abort it.
- return new Promise(() => {});
- }
- return new Promise((resolve) => {
- resolveSecond = resolve;
- });
- });
-
- const { element, onChange, reattach } = setupWithPreParse('hello', preParse);
-
- typeChar(element, 'a');
- expect(signals[0].aborted).toBe(false);
-
- // Re-attach the MutationObserver so the next typeChar's mutation is
- // recorded. flushChanges() disconnects the observer; in production a
- // React commit re-attaches via useLayoutEffect — we simulate that
- // commit explicitly with a rerender.
- reattach();
-
- typeChar(element, 'b');
- // The first signal must now be aborted by the second flush.
- expect(signals[0].aborted).toBe(true);
- expect(signals[1].aborted).toBe(false);
-
- resolveSecond!({ type: 'root', children: [] });
- await Promise.resolve();
- await Promise.resolve();
-
- // Only the second (most recent) flush reaches onChange. With the
- // deferred-revert flow the DOM stays mutated through the first
- // preParse, so the second typeChar appends to "helloa" — yielding
- // "helloab".
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange.mock.calls[0][0]).toBe('helloab\n');
- });
-
- it('falls back to onChange without preParseResult when preParse rejects (non-abort)', async () => {
- let rejectPreParse: ((reason?: unknown) => void) | undefined;
- const preParse = vi.fn(
- () =>
- new Promise((_resolve, reject) => {
- rejectPreParse = reject;
- }),
- );
-
- const { element, onChange } = setupWithPreParse('hello', preParse);
-
- typeChar(element, 'a');
- rejectPreParse!(new Error('parse failed'));
- await Promise.resolve();
- await Promise.resolve();
-
- // Fail-open: the typed source still propagates so the controlled
- // state and the live DOM stay consistent. The third (preParseResult)
- // argument is omitted to signal "no parse available".
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange.mock.calls[0][0]).toBe('helloa\n');
- expect(onChange.mock.calls[0][2]).toBeUndefined();
- });
-
- it('drops the rejection silently when preParse is aborted by a newer keystroke', async () => {
- const deferreds: Array<{
- resolve: (value: unknown) => void;
- reject: (reason?: unknown) => void;
- }> = [];
- const preParse = vi.fn(
- () =>
- new Promise((resolve, reject) => {
- deferreds.push({ resolve, reject });
- }),
- );
-
- const { element, onChange, reattach } = setupWithPreParse('hello', preParse);
-
- typeChar(element, 'a');
- expect(deferreds).toHaveLength(1);
-
- // A second keystroke aborts the first preParse before its rejection
- // arrives. The aborted rejection must NOT trigger a fallback commit.
- reattach();
- typeChar(element, 'b');
- expect(deferreds).toHaveLength(2);
-
- deferreds[0].reject(new Error('aborted-stale'));
- await Promise.resolve();
- await Promise.resolve();
-
- // No commit yet — the second preParse is still pending.
- expect(onChange).not.toHaveBeenCalled();
-
- // Resolving the second preParse commits the combined edit normally.
- deferreds[1].resolve('parsed-b');
- await Promise.resolve();
- await Promise.resolve();
-
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange.mock.calls[0][0]).toBe('helloab\n');
- expect(onChange.mock.calls[0][2]).toBe('parsed-b');
- });
-
- it('aborts in-flight preParse on unmount', async () => {
- const signals: AbortSignal[] = [];
- const preParse = vi.fn((_text: string, _pos: Position, signal: AbortSignal) => {
- signals.push(signal);
- return new Promise(() => {});
- });
-
- const { element, unmount } = setupWithPreParse('hello', preParse);
-
- typeChar(element, 'a');
- expect(signals[0].aborted).toBe(false);
-
- unmount();
- expect(signals[0].aborted).toBe(true);
- });
-
- it('bypasses preParse on Enter so onChange fires synchronously', () => {
- const preParse = vi.fn(() => new Promise(() => {}));
-
- const { element, onChange } = setupWithPreParse('hello', preParse);
- placeSelection(element, 5);
-
- element.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
- );
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'Enter', bubbles: true, cancelable: true }),
- );
-
- // Enter routes through edit.insert (sync onChange) AND triggers a
- // bypass flush on keyup. Either way, preParse must NOT have gated
- // the React state sync, and onChange must have a 2-arg call.
- expect(onChange).toHaveBeenCalled();
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
- // The bypass path passes only (text, position) — preParseResult is undefined.
- expect(lastCall[2]).toBeUndefined();
- });
-
- it('does not lose a straggler keystroke that arrives before the in-flight preParse resolves', async () => {
- // Regression for a typing-fast bug: when preParse('helloa') resolved
- // BEFORE the next keystroke's keyup fired, commit() used to revert
- // both the 'a' AND the straggler 'b' mutations and then fire
- // onChange('helloa'). React rendered "helloa" — the user's 'b' was
- // permanently lost. The fix: commit must bail when stragglers are
- // detected and let the straggler's own keyup-triggered flush
- // produce a fresher commit that includes the new character.
- let resolveFirst: ((value: unknown) => void) | undefined;
- let resolveSecond: ((value: unknown) => void) | undefined;
- let callCount = 0;
- const preParse = vi.fn((_text: string, _pos: Position, _signal: AbortSignal) => {
- callCount += 1;
- return new Promise((resolve) => {
- if (callCount === 1) {
- resolveFirst = resolve;
- } else {
- resolveSecond = resolve;
- }
- });
- });
-
- const { element, onChange, reattach } = setupWithPreParse('hello', preParse);
-
- // Type 'a' the normal way: DOM mutation + keyup → flushChanges →
- // preParse('helloa') in flight.
- typeChar(element, 'a');
- expect(preParse).toHaveBeenCalledTimes(1);
- expect(onChange).not.toHaveBeenCalled();
-
- // Simulate a 'b' keydown landing in the DOM BEFORE preParse('helloa')
- // resolves and BEFORE the 'b' keyup fires. The MutationObserver picks
- // it up asynchronously.
- element.textContent = 'helloab';
- placeSelection(element, 6);
- // Let the observer's microtask deliver the mutation into state.queue.
- await Promise.resolve();
-
- // Now resolve preParse('helloa'). With the bug, commit would revert
- // both mutations and call onChange('helloa'). With the fix, commit
- // detects the straggler and bails — onChange must NOT fire and the
- // DOM must still contain the 'b'.
- resolveFirst!({ type: 'root', children: [] });
- await Promise.resolve();
- await Promise.resolve();
-
- expect(onChange).not.toHaveBeenCalled();
- expect(element.textContent).toBe('helloab');
-
- // The 'b' keyup eventually fires → flushChanges sees the queued
- // mutations, computes content = "helloab", starts preParse('helloab').
- // No reattach() needed because the bail kept the observer connected.
- element.dispatchEvent(
- new KeyboardEvent('keyup', { key: 'b', bubbles: true, cancelable: true }),
- );
- expect(preParse).toHaveBeenCalledTimes(2);
- expect(preParse.mock.calls[1][0]).toBe('helloab\n');
-
- resolveSecond!({ type: 'root', children: [] });
- await Promise.resolve();
- await Promise.resolve();
-
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange.mock.calls[0][0]).toBe('helloab\n');
-
- // Sanity: the next round trip still works after a bailed commit.
- reattach();
- typeChar(element, 'c');
- });
- });
-
- // ---------------------------------------------------------------------------
- describe('cleanup', () => {
- it('removes event listeners on unmount', () => {
- const windowRemove = vi.spyOn(window, 'removeEventListener');
- const documentRemove = vi.spyOn(document, 'removeEventListener');
-
- const { element, unmount } = setup('hello');
- const elementRemove = vi.spyOn(element, 'removeEventListener');
-
- unmount();
-
- expect(windowRemove).toHaveBeenCalledWith('keydown', expect.any(Function));
- expect(documentRemove).toHaveBeenCalledWith('selectstart', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('paste', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('copy', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('cut', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('keyup', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('mouseup', expect.any(Function));
- expect(elementRemove).toHaveBeenCalledWith('focus', expect.any(Function));
-
- windowRemove.mockRestore();
- documentRemove.mockRestore();
- });
- });
-
- // ---------------------------------------------------------------------------
- // MutationObserver
- // ---------------------------------------------------------------------------
- describe('MutationObserver', () => {
- it('observes the element for mutations', () => {
- const observeSpy = vi.spyOn(MutationObserver.prototype, 'observe');
-
- setup('hello');
-
- expect(observeSpy).toHaveBeenCalledWith(
- expect.any(HTMLElement),
- expect.objectContaining({
- characterData: true,
- characterDataOldValue: true,
- childList: true,
- subtree: true,
- }),
- );
-
- observeSpy.mockRestore();
- });
-
- it('disconnects the observer on unmount', () => {
- const disconnectSpy = vi.spyOn(MutationObserver.prototype, 'disconnect');
-
- const { unmount } = setup('hello');
- unmount();
-
- expect(disconnectSpy).toHaveBeenCalled();
-
- disconnectSpy.mockRestore();
- });
- });
-
- // ---------------------------------------------------------------------------
- // Multiline content
- // ---------------------------------------------------------------------------
- describe('multiline content', () => {
- it('getState returns correct text for multiline content', () => {
- const { result, element } = setup('line 1\nline 2\nline 3');
- placeSelection(element, 0);
-
- const state = result.current.getState();
- expect(state.text).toBe('line 1\nline 2\nline 3\n');
- });
-
- it('getState tracks line number correctly', () => {
- const { result, element } = setup('line 1\nline 2\nline 3');
- // Place caret at the start of line 2
- placeSelection(element, 7);
-
- const state = result.current.getState();
- expect(state.position.line).toBe(1);
- });
- });
-
- // ---------------------------------------------------------------------------
- // Edge cases
- // ---------------------------------------------------------------------------
- describe('edge cases', () => {
- it('handles empty content', () => {
- // An empty string sets textContent to '' which creates no child nodes.
- // toString() assumes firstChild exists, so this is a known edge case
- // that crashes. Verify the hook initializes without throwing.
- const element = document.createElement('pre');
- element.textContent = '';
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn();
-
- // The hook should mount without error (toString is only called on interaction)
- const { result } = renderHook(() => useEditable(ref, onChange));
- expect(result.current).toBeDefined();
- });
-
- it('handles null element ref gracefully', () => {
- const ref: { current: HTMLElement | null } = { current: null };
- const onChange = vi.fn();
-
- // Should not throw
- const { result } = renderHook(() => useEditable(ref, onChange));
- expect(result.current).toBeDefined();
- });
-
- it('works without options parameter', () => {
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
-
- const ref = { current: element };
- const onChange = vi.fn();
-
- // Should not throw when opts is undefined
- const { result } = renderHook(() => useEditable(ref, onChange));
- expect(result.current).toBeDefined();
- });
-
- it('handles repeated key events (key held down)', () => {
- const { element } = setup('hello');
- placeSelection(element, 0);
-
- const event = new KeyboardEvent('keydown', {
- key: 'a',
- repeat: true,
- bubbles: true,
- cancelable: true,
- });
- // Should not throw
- element.dispatchEvent(event);
-
- expect(element).toBeDefined();
- });
-
- it('does not throw when the debounced repeat-flush fires after the selection is cleared', () => {
- // Regression: the 100ms debounce scheduled from onKeyDown(repeat) calls
- // flushChanges → getPosition → getCurrentRange. If the user moves focus
- // / clears the selection (e.g. clicks elsewhere) before the timer fires,
- // getCurrentRange throws `useEditable: expected an active selection` and
- // surfaces as an unhandled error in the test runner. The callback must
- // bail out gracefully when no live selection remains inside the editable.
- vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
- try {
- const { element } = setup('hello');
- placeSelection(element, 0);
-
- element.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'a',
- repeat: true,
- bubbles: true,
- cancelable: true,
- }),
- );
-
- // User clicks away / focus moves elsewhere — selection is gone.
- window.getSelection()?.removeAllRanges();
-
- // Advance past the 100ms debounce; the timer must not throw.
- expect(() => vi.advanceTimersByTime(150)).not.toThrow();
- } finally {
- vi.useRealTimers();
- }
- });
-
- it('does not restore stale cursor position when a re-render fires during key-hold', () => {
- // Regression: during the 100ms debounce window (repeatFlushId is set),
- // a re-render caused by an external setState (e.g. async enhancer) was
- // running the no-deps useLayoutEffect and calling setCurrentRange with the
- // stale state.position, teleporting the cursor back on every repeat
- // keydown → re-render cycle.
- const element = document.createElement('pre');
- element.textContent = 'hello';
- document.body.appendChild(element);
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- const { result, rerender } = renderHook(
- (props) => useEditable(props.ref, props.onChange, props.opts),
- { initialProps: { ref, onChange, opts: {} } },
- );
-
- placeSelection(element, 0);
-
- // Establish a non-null state.position via edit.update.
- // This simulates the state after the user's first edit has flushed.
- act(() => {
- result.current.update('hello');
- });
-
- // Move the cursor to position 2 (mid-word) to simulate forward typing
- placeSelection(element, 2);
-
- // Dispatch a repeat keydown — this sets state.repeatFlushId (debounce timer)
- const keyDown = new KeyboardEvent('keydown', {
- key: 'x',
- code: 'KeyX',
- repeat: true,
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyDown);
-
- // Snapshot cursor position before the incidental re-render
- const selectionBefore = window.getSelection()!.getRangeAt(0).cloneRange();
-
- // Trigger a re-render while the debounce timer is active.
- // Without the fix, the no-deps useLayoutEffect would call setCurrentRange
- // with state.position (offset 0 from edit.update) and jump the cursor back.
- rerender({ ref, onChange: vi.fn(), opts: {} });
-
- // Cursor must remain at position 2, not jump back to state.position (0)
- const selectionAfter = window.getSelection()!.getRangeAt(0);
- expect(selectionAfter.startContainer).toBe(selectionBefore.startContainer);
- expect(selectionAfter.startOffset).toBe(selectionBefore.startOffset);
-
- // Clean up the debounce timer via keyup
- const keyUp = new KeyboardEvent('keyup', {
- key: 'x',
- code: 'KeyX',
- bubbles: true,
- cancelable: true,
- });
- element.dispatchEvent(keyUp);
- });
-
- it('preserves the caret across a host re-render after a click-only focus (no typing)', () => {
- // Regression: a plain click that places a collapsed caret does NOT
- // fire `selectstart`, so without explicit capture on `mouseup`/`focus`
- // `state.position` stays null. When the host then re-renders (e.g.
- // expanding a collapsed code block), the unconditional restore in the
- // first useLayoutEffect skips, and the DOM mutations from the
- // re-render clobber the browser's selection — the user sees the
- // caret jump and/or text get selected. After this fix, mouseup
- // captures the position so the restore re-applies it on re-render.
- const element = document.createElement('pre');
- element.textContent = 'line one\nline two\nline three\n';
- document.body.appendChild(element);
- const ref = { current: element };
- const onChange = vi.fn<(text: string, position: Position) => void>();
-
- const { rerender } = renderHook(
- (props) => useEditable(props.ref, props.onChange, props.opts),
- { initialProps: { ref, onChange, opts: {} as Parameters[2] } },
- );
-
- // Simulate a click that places the caret mid-line. The user did NOT
- // type anything, so `state.position` is only populated via the new
- // `mouseup` capture (selectstart does not fire for collapsed clicks).
- placeSelection(element, 12); // inside "line two"
- element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
-
- // Capture the DOM-level selection that the user currently sees.
- const before = window.getSelection()!.getRangeAt(0).cloneRange();
-
- // Simulate the host re-rendering (e.g. a "show more" button expanding
- // the visible region). Crucially, do NOT change content — only the
- // re-render itself is enough to expose the bug because the no-deps
- // useLayoutEffect runs on every commit.
- rerender({ ref, onChange, opts: {} as Parameters[2] });
-
- const after = window.getSelection()!.getRangeAt(0);
- expect(after.collapsed).toBe(true);
- expect(after.startContainer).toBe(before.startContainer);
- expect(after.startOffset).toBe(before.startOffset);
- });
- });
-});
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
deleted file mode 100644
index 3da5506b1..000000000
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-// `useEditable` is the lightweight, always-mounted shell for live code editing.
-// It owns the editing state and refs (undo history, caret, the MutationObserver
-// ref) so they survive across renders, but the heavy runtime — the
-// contentEditable setup and the keyboard/paste/caret handlers — lives in the
-// separately-loaded `./EditableEngine` chunk. `contentEditable` is applied to
-// the element only once that engine resolves, so read-only code blocks never
-// pull the engine into their bundle. The engine factory is injected (typically
-// by `CodeProvider` via context); a built-in fallback keeps editing working
-// without a provider. The original fork attribution lives in `./EditableEngine`.
-
-import * as React from 'react';
-import type { Position } from './useEditableUtils';
-import type {
- Bounds,
- CreateEditableEngine,
- Edit,
- EditableEngine,
- EditableEngineContext,
- Options,
- State,
-} from './EditableEngine';
-import {
- peekEditingEngine,
- loadEditingEngine,
- preloadEditingEngine,
- resetEditingEngineCache,
-} from './editingEngineCache';
-
-export type { Position } from './useEditableUtils';
-export type { Edit, Options } from './EditableEngine';
-export type { EditingEngineLoader } from './editingEngineCache';
-
-// A fresh empty snapshot per call — the pre-load `edit.getState()` must not hand
-// out a shared mutable object, or one caller mutating it would corrupt the
-// snapshot every other pre-load caller sees.
-const emptySnapshot = (): { text: string; position: Position } => ({
- text: '',
- position: { position: 0, extent: 0, content: '', line: 0 },
-});
-
-// The resolved engine is cached in the shared `editingEngineCache` (so the
-// FIRST editable block resolves the loader once and every block after attaches
-// synchronously — and `useSourceEditing` shares the same warm module). These
-// are back-compat aliases over that cache; the param is now an
-// `EditingEngineLoader` (resolves the module, not just the factory).
-
-/**
- * Eagerly loads the editing engine and primes the shared cache so the next
- * editable block attaches synchronously instead of after a load round-trip.
- * Optional — `useEditable` loads on demand anyway. Pass the provider's
- * `editingEngineLoader` to share its deduplication.
- */
-export const preloadEditableEngine = preloadEditingEngine;
-
-/**
- * Clears the shared editing-engine cache so the next editable block resolves its
- * loader from scratch. Intended for tests that exercise the cold path.
- */
-export const resetEditableEngineCache = resetEditingEngineCache;
-
-/**
- * The lightweight, always-mounted shell for live code editing. Owns the editing
- * state/refs and a stable `edit` proxy; the heavy runtime is loaded on demand
- * from `./EditableEngine` and `contentEditable` is applied only once it resolves.
- *
- * The host element (`elementRef.current`) is expected to be **stable for the
- * lifetime of the hook** once the block is editable: the engine attaches once
- * and its setup effect does not re-run on a node swap, so a caller that replaces
- * the bound element in place would leave `contentEditable` on the stale node.
- */
-export const useEditable = (
- elementRef: { current: HTMLElement | undefined | null },
- onChange: (text: string, position: Position, preParseResult?: TPreParseResult) => void,
- opts?: Options,
-): Edit => {
- // Normalize once into a non-optional local so the effects below can read
- // `config.X` directly without any non-null assertions on `opts`.
- const config: Options = opts ?? {};
-
- const unblock = React.useState([])[1];
-
- // The editing state bag, the visible-region bounds, and a config snapshot are
- // all mutable refs the engine reads/writes. They're synced in the layout effect
- // below (never during render — React refs must not be touched while rendering).
- const stateRef = React.useRef(null);
- const observerRef = React.useRef(null);
- const boundsRef = React.useRef({});
- const configRef = React.useRef(config);
-
- const [engine, setEngine] = React.useState(null);
- const engineRef = React.useRef(null);
- // Fires `onActivate` once per block lifetime, the first time the block engages
- // for editing (mount in `'eager'`; hover/focus/click in `'interaction'`).
- const activatedRef = React.useRef(false);
-
- // Stable Edit proxy. Delegates to the loaded engine; before the engine
- // resolves the mutators are no-ops and `getState` returns an empty snapshot
- // (matching the historical pre-mount behavior).
- const [edit] = React.useState(() => ({
- update(content: string) {
- engineRef.current?.edit.update(content);
- },
- insert(append: string, offset?: number) {
- engineRef.current?.edit.insert(append, offset);
- },
- move(pos: number | { row: number; column: number }) {
- engineRef.current?.edit.move(pos);
- },
- getState() {
- return engineRef.current?.edit.getState() ?? emptySnapshot();
- },
- }));
-
- // Keep the mutable refs current. Runs every render in a layout effect (not
- // during render, so the React Compiler ref rules are satisfied) and before the
- // resolve effect below, so the engine is always built against fresh values.
- // The engine's handlers read these refs at event time, long after this commits.
- React.useLayoutEffect(() => {
- let editingState = stateRef.current;
- if (editingState === null) {
- editingState = {
- disconnected: false,
- onChange,
- pendingContent: null,
- queue: [],
- history: [],
- historyAt: -1,
- lastCommittedContent: null,
- domDirty: false,
- position: null,
- repeatFlushId: null,
- skipNextRestore: false,
- preParseAbort: null,
- };
- stateRef.current = editingState;
- } else {
- // `onChange` can change without a remount (e.g. controlled code updates the
- // closure), so refresh it every render. It's declared as a method on
- // `State`, so the assignment needs no cast.
- editingState.onChange = onChange;
- }
- const bounds = boundsRef.current;
- bounds.minColumn = config.minColumn;
- bounds.minRow = config.minRow;
- bounds.maxRow = config.maxRow;
- bounds.onBoundary = config.onBoundary;
- bounds.caretSelector = config.caretSelector;
- bounds.preParse = config.preParse;
- configRef.current = config;
- });
-
- // Resolve the engine when the block is editable. `'eager'` (default) loads on
- // mount; `'interaction'` defers the load until the user engages: hover
- // (pointerenter) warms the chunk so the eventual commit is instant, and focus
- // or click commits (loads + attaches). `contentEditable` is applied only after
- // the engine resolves (via `setup`).
- React.useLayoutEffect(() => {
- const editingState = stateRef.current;
- if (
- typeof window === 'undefined' ||
- config.disabled ||
- !elementRef.current ||
- !editingState ||
- engineRef.current
- ) {
- return undefined;
- }
-
- const loader = config.engineLoader;
- const ctx: EditableEngineContext = {
- elementRef,
- state: editingState,
- observerRef,
- boundsRef,
- configRef,
- unblock,
- };
-
- const attach = (create: CreateEditableEngine) => {
- if (engineRef.current) {
- return;
- }
- const created = create(ctx);
- engineRef.current = created;
- setEngine(created);
- };
-
- // Notify the host the block has engaged for editing, exactly once. The host
- // (e.g. `CodeHighlighter`) uses this to warm the rest of the live-editing
- // dependencies — grammars and the worker — at the activation moment.
- const notifyActivated = () => {
- if (activatedRef.current) {
- return;
- }
- activatedRef.current = true;
- configRef.current.onActivate?.();
- };
-
- let cancelled = false;
- // Attach the engine: synchronously from the warm shared cache (a later block
- // on the page, or a test pre-warm), otherwise via the loader. Fail open on a
- // load error — leave the block as read-only plain text rather than crash.
- const load = () => {
- const warmModule = peekEditingEngine();
- if (warmModule) {
- attach(warmModule.createEditableEngine);
- return;
- }
- Promise.resolve(loadEditingEngine(loader))
- .then((mod) => {
- if (!cancelled) {
- attach(mod.createEditableEngine);
- }
- })
- .catch(() => {});
- };
-
- if ((config.activation ?? 'eager') === 'eager') {
- notifyActivated();
- load();
- return () => {
- cancelled = true;
- };
- }
-
- // 'interaction': defer attaching (and thus `contentEditable`) until the user
- // engages the block, regardless of whether the engine is already cached.
- // Hover (pointerenter) warms the chunk so the eventual commit is instant;
- // focus and pointerdown commit (load + attach).
- const element = elementRef.current;
- const warm = () => {
- notifyActivated();
- preloadEditingEngine(loader).catch(() => {});
- };
- const commit = () => {
- notifyActivated();
- load();
- };
- element.addEventListener('pointerenter', warm);
- element.addEventListener('pointerdown', commit);
- element.addEventListener('focus', commit);
- return () => {
- cancelled = true;
- element.removeEventListener('pointerenter', warm);
- element.removeEventListener('pointerdown', commit);
- element.removeEventListener('focus', commit);
- };
- // `config.disabled` drives the re-run once the block becomes editable; the
- // refs the effect reads are stable (and a ref can't be a dependency), so they
- // are intentionally omitted.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [config.disabled, config.engineLoader, config.activation]);
-
- // Per-render observe + caret-restore, delegated to the engine once it exists.
- React.useLayoutEffect(() => {
- if (typeof window === 'undefined' || !engine) {
- return undefined;
- }
- return engine.observeAndRestore();
- });
-
- // contentEditable setup + handler binding, delegated to the engine. Re-runs
- // once the engine resolves and on `disabled`/`indentation` changes (the engine
- // re-reads them and the previous cleanup detaches contentEditable first).
- React.useLayoutEffect(() => {
- if (typeof window === 'undefined' || !engine) {
- return undefined;
- }
- return engine.setup();
- }, [engine, config.disabled, config.indentation]);
-
- return edit;
-};
diff --git a/packages/docs-infra/src/useCode/useEditableUtils.ts b/packages/docs-infra/src/useCode/useEditableUtils.ts
deleted file mode 100644
index e66ffe71a..000000000
--- a/packages/docs-infra/src/useCode/useEditableUtils.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-/*
- * Pure DOM/text helpers extracted from useEditable.ts. None of these
- * touch React state or the hook's internal `state` object — they only
- * read from / mutate the DOM and the browser Selection. Kept in a
- * sibling file (per AGENTS.md docs-infra rule 2.3) so the main hook
- * stays focused on lifecycle wiring and event handling.
- */
-
-export interface Position {
- position: number;
- extent: number;
- content: string;
- line: number;
- /**
- * Set only when this position originates from an undo/redo navigation, naming
- * the direction. On `'undo'` the caret is the PRE-edit position (it did not
- * move as forward typing would), so derived state (e.g. the comment/highlight
- * map) must reverse the edit rather than assume a post-edit caret. Absent for
- * a fresh edit.
- */
- history?: 'undo' | 'redo';
- /**
- * On an `'undo'`, the 0-indexed line the reversed edit was anchored at — its
- * POST-edit caret line, which can differ from this (destination) caret when
- * the edit ran over a selection that didn't start at the caret (e.g. Select
- * All). Lets derived state reverse the edit at the exact line the forward
- * edit pivoted on instead of guessing from the destination caret.
- */
- historyPivotLine?: number;
- /**
- * Set when the edit removed whole lines starting at the very beginning of a
- * line (a selection delete whose start was at column 0). The post-edit caret
- * then sits on the line that shifted up from BELOW the deletion, so the edit's
- * anchor is one line higher than the caret implies. Derived state (comment
- * map) must drop its anchor by one or markers on the deleted first line are
- * stranded. Rides through undo as well so the reversal anchors identically.
- */
- deletedFromLineStart?: boolean;
- /**
- * Set when the tracked selection is a BACKWARD range — its focus (the moving
- * end) sits at the range START, above/before the anchor. `position`/`extent`
- * only describe the range's extent, not which end is the focus, so a backward
- * Shift+Arrow selection that survives a host re-render would otherwise be
- * rebuilt as a forward range (focus flipped to the bottom), making the next
- * Shift+Arrow extend from the wrong end. The restore honors this flag by
- * collapsing to the anchor then extending back to the focus. Absent for a
- * collapsed caret or a forward selection.
- */
- backward?: boolean;
-}
-
-export const getCurrentRange = (): Range => {
- const selection = window.getSelection();
- if (!selection || selection.rangeCount === 0) {
- // Internal helper — only called from event handlers and edit methods
- // that have already verified there is an active selection. Throwing
- // here surfaces contract violations early instead of letting them
- // explode further down the call stack (matching the prior implicit
- // `DOMException` from `getRangeAt(0)` on an empty selection).
- throw new Error('useEditable: expected an active selection');
- }
- return selection.getRangeAt(0);
-};
-
-export const setCurrentRange = (range: Range) => {
- const selection = window.getSelection();
- if (!selection) {
- return;
- }
- selection.empty();
- selection.addRange(range);
-};
-
-/**
- * Narrow a `Node | null` to `Element | null` using a runtime check so
- * downstream code can reason about element-only APIs without a cast.
- */
-export const asElement = (node: Node | null | undefined): Element | null =>
- node instanceof Element ? node : null;
-
-/**
- * Pull the next element out of a `SHOW_ELEMENT` `TreeWalker` with a
- * runtime check rather than a type cast. Tree walkers configured for
- * `SHOW_ELEMENT` only emit elements in practice, but the DOM type
- * exposes `Node | null`.
- */
-export const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode());
-
-export const isUndoRedoKey = (event: KeyboardEvent): boolean =>
- (event.metaKey || event.ctrlKey) && !event.altKey && event.code === 'KeyZ';
-
-export const isPlaintextInputKey = (event: KeyboardEvent): boolean => {
- const usesAltGraph =
- typeof event.getModifierState === 'function' && event.getModifierState('AltGraph');
-
- return (
- event.key.length === 1 && !event.metaKey && !event.ctrlKey && (!event.altKey || usesAltGraph)
- );
-};
-
-export const toString = (element: HTMLElement): string => {
- const content = element.textContent || '';
-
- // contenteditable Quirk: Without plaintext-only a pre/pre-wrap element must always
- // end with at least one newline character
- if (content[content.length - 1] !== '\n') {
- return `${content}\n`;
- }
-
- return content;
-};
-
-export interface LineInfo {
- /** Full text of the requested line. */
- currentLine: string;
- /** Full text of `lineIndex - 1`. Empty when `lineIndex <= 0`. */
- prevLine: string;
- /** Full text of `lineIndex + 1`. Empty when there is no next line. */
- nextLine: string;
- /**
- * True when a real line follows `currentLine` — including a blank
- * line. False when the document ends at `currentLine` (matching the
- * old `toString(element).split('\n').slice(0, -1)` semantics where
- * the phantom empty entry after the trailing `\n` does not count as
- * a next line).
- */
- hasNextLine: boolean;
-}
-
-/**
- * Walk text nodes to extract the requested line plus its immediate
- * neighbors without materializing the full document text or splitting
- * it into a per-line array. Used by per-keystroke handlers (arrow keys,
- * Backspace, gutter snapping) so they stay O(chars-on-touched-lines)
- * instead of O(document-length) on every event.
- *
- * Walks each text node in document order and slices contiguous segments
- * directly into the relevant accumulator (`prevLine` / `currentLine` /
- * `nextLine`). Skips chunks belonging to lines we don't care about and
- * exits as soon as the trailing `\n` of `lineIndex + 1` is consumed.
- *
- * Mirrors `toString(element).split('\n').slice(0, -1)` semantics:
- *
- * - `hasNextLine` is `true` whenever a real line follows `currentLine`,
- * even if that line is blank — `"a\n\nb\n"` reports a next line for
- * row 0. The phantom empty entry that `split` produces after the
- * document's trailing `\n` is intentionally ignored.
- * - The implicit trailing newline that `toString` appends when the DOM
- * doesn't end with one has no effect: we walk raw text content.
- */
-export const getLineInfo = (element: HTMLElement, lineIndex: number): LineInfo => {
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let currentLine = '';
- let prevLine = '';
- let nextLine = '';
- let hasNextLine = false;
- let line = 0;
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- let segStart = 0;
- for (let i = 0; i < text.length; i += 1) {
- if (text[i] !== '\n') {
- continue;
- }
- // Flush the segment that lives on `line` before crossing the newline.
- if (segStart < i) {
- const segment = text.slice(segStart, i);
- if (line === lineIndex - 1) {
- prevLine += segment;
- } else if (line === lineIndex) {
- currentLine += segment;
- } else if (line === lineIndex + 1) {
- nextLine += segment;
- }
- }
- // We're about to cross the `\n` that terminates `line`. If `line`
- // is the next line, we've now fully read it and confirmed it
- // exists (a terminator means there is at least one more position
- // in the document past `currentLine`'s end).
- if (line === lineIndex + 1) {
- hasNextLine = true;
- return { currentLine, prevLine, nextLine, hasNextLine };
- }
- line += 1;
- segStart = i + 1;
- }
- // Tail segment of this text node belongs to `line` (no newline yet).
- if (segStart < text.length) {
- const segment = text.slice(segStart);
- if (line === lineIndex - 1) {
- prevLine += segment;
- } else if (line === lineIndex) {
- currentLine += segment;
- } else if (line === lineIndex + 1) {
- // An unterminated tail on `lineIndex + 1` is the document's
- // last (real) line — it counts as a next line. The phantom
- // empty entry produced by `toString`'s trailing `\n` has no
- // tail, so it correctly leaves `hasNextLine` false.
- nextLine += segment;
- hasNextLine = true;
- }
- }
- }
- return { currentLine, prevLine, nextLine, hasNextLine };
-};
-
-/**
- * Convert a `(row, column)` coordinate into an absolute character offset
- * by counting newlines through the editable's text nodes, exiting the
- * moment we land on the requested row. Avoids the
- * `toString(element).split('\n').slice(0, row).join('\n').length`
- * round-trip — that pattern allocates the full document string and a
- * full per-line array on every `edit.move({row, column})` call.
- *
- * If the row is past the end of the document, returns the document
- * length plus `column` so the eventual `makeRange` clamps gracefully.
- */
-export const getOffsetAtLineColumn = (
- element: HTMLElement,
- row: number,
- column: number,
-): number => {
- if (row <= 0) {
- return Math.max(0, column);
- }
- let offset = 0;
- let line = 0;
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- for (let i = 0; i < text.length; i += 1) {
- offset += 1;
- if (text[i] === '\n') {
- line += 1;
- if (line === row) {
- return offset + column;
- }
- }
- }
- }
- return offset + column;
-};
-
-export const repairUnexpectedLineMerge = (
- newContent: string,
- previousContent: string | null,
- position: Position,
-): string => {
- if (previousContent == null || position.extent !== 0) {
- return newContent;
- }
-
- const previousLines = previousContent.split('\n');
- const nextLines = newContent.split('\n');
-
- if (nextLines.length >= previousLines.length) {
- return newContent;
- }
-
- const cursorLine = position.line;
-
- for (let i = 0; i < cursorLine && i < nextLines.length; i += 1) {
- if (nextLines[i] !== previousLines[i]) {
- return newContent;
- }
- }
-
- const linesLost = previousLines.length - nextLines.length;
- const mergedPreviousContent = previousLines
- .slice(cursorLine + 1, cursorLine + 1 + linesLost)
- .join('');
-
- if (!nextLines[cursorLine]?.endsWith(mergedPreviousContent)) {
- return newContent;
- }
-
- const editedCursorLine = nextLines[cursorLine].slice(
- 0,
- nextLines[cursorLine].length - mergedPreviousContent.length,
- );
-
- if (editedCursorLine === previousLines[cursorLine]) {
- return newContent;
- }
-
- return [
- ...nextLines.slice(0, cursorLine),
- editedCursorLine,
- ...previousLines.slice(cursorLine + 1, cursorLine + 1 + linesLost),
- ...nextLines.slice(cursorLine + 1),
- ].join('\n');
-};
-
-const setStart = (range: Range, node: Node, offset: number) => {
- const length = (node.textContent ?? '').length;
- if (offset < length) {
- range.setStart(node, offset);
- } else {
- range.setStartAfter(node);
- }
-};
-
-const setEnd = (range: Range, node: Node, offset: number) => {
- const length = (node.textContent ?? '').length;
- if (offset < length) {
- range.setEnd(node, offset);
- } else {
- range.setEndAfter(node);
- }
-};
-
-export const getPosition = (element: HTMLElement): Position => {
- const range = getCurrentRange();
- const extent = !range.collapsed ? range.toString().length : 0;
-
- // Fast path: cursor is in a text node (Chrome/Safari with plaintext-only, and
- // Firefox after edit.insert repositions the cursor). Walk text nodes to count
- // characters without allocating an O(cursor-position) string.
- if (range.startContainer.nodeType === Node.TEXT_NODE) {
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let position = 0;
- let line = 0;
- let lineContent = '';
-
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- const isTarget = node === range.startContainer;
- const upTo = isTarget ? range.startOffset : text.length;
-
- let segStart = 0;
- for (let i = 0; i < upTo; i += 1) {
- if (text[i] === '\n') {
- line += 1;
- lineContent = '';
- segStart = i + 1;
- }
- }
- lineContent += text.slice(segStart, upTo);
- position += upTo;
-
- if (isTarget) {
- break;
- }
- }
-
- return { position, extent, content: lineContent, line };
- }
-
- // Firefox fallback: cursor may be at an element boundary (e.g. after a click
- // before any edit). Use Range.toString() to extract the pre-cursor text.
- // Firefox Quirk: Since plaintext-only is unsupported, the selection can land
- // on element nodes rather than text nodes.
- const untilRange = document.createRange();
- untilRange.setStart(element, 0);
- untilRange.setEnd(range.startContainer, range.startOffset);
- let content = untilRange.toString();
- const position = content.length;
- const lines = content.split('\n');
- const line = lines.length - 1;
- content = lines[line];
- return { position, extent, content, line };
-};
-
-export const makeRange = (element: HTMLElement, start: number, end?: number): Range => {
- if (start <= 0) {
- start = 0;
- }
- if (!end || end < 0) {
- end = start;
- }
-
- const range = document.createRange();
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let current = 0;
- let position = start;
-
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const length = (node.textContent ?? '').length;
- if (current + length >= position) {
- const offset = position - current;
- if (position === start) {
- setStart(range, node, offset);
- if (end === start) {
- break;
- }
- position = end;
- if (current + length >= position) {
- setEnd(range, node, position - current);
- break;
- }
- // end is in a later node — fall through to advance current
- } else {
- setEnd(range, node, offset);
- break;
- }
- }
- current += length;
- }
-
- return range;
-};
-
-/** Walk to the next text node in document order without allocating a TreeWalker. */
-const nextTextNode = (node: Node): Node | null => {
- let current: Node | null = node;
- // Walk up and across siblings until we find a branch to descend into.
- while (current) {
- if (current.nextSibling) {
- current = current.nextSibling;
- // Descend to the first text node.
- while (current.firstChild) {
- current = current.firstChild;
- }
- if (current.nodeType === Node.TEXT_NODE) {
- return current;
- }
- // Not a text leaf — continue walking siblings from here.
- continue;
- }
- current = current.parentNode;
- }
- return null;
-};
-
-/**
- * After makeRange positions a collapsed cursor at a newline boundary via
- * setStartAfter(textNode), the cursor ends up inside the *previous* line span
- * (after the '\n'). This adjusts the range forward to offset 0 of the
- * next text node so the cursor renders on the correct visual line.
- */
-export const adjustCursorAtNewlineBoundary = (range: Range): void => {
- if (!range.collapsed) {
- return;
- }
-
- const { startContainer, startOffset } = range;
- const startText = startContainer.textContent ?? '';
-
- // Case 1: cursor is in a text node at the very end and that text ends with '\n'
- if (
- startContainer.nodeType === Node.TEXT_NODE &&
- startOffset === startText.length &&
- startText.endsWith('\n')
- ) {
- const next = nextTextNode(startContainer);
- if (next) {
- range.setStart(next, 0);
- range.collapse(true);
- }
- return;
- }
-
- // Case 2: cursor is at an element boundary where the previous child is a
- // text node ending with '\n' (happens when setStartAfter places us here)
- if (startContainer.nodeType === Node.ELEMENT_NODE && startOffset > 0) {
- const prevChild = startContainer.childNodes[startOffset - 1];
- const prevText = prevChild?.textContent ?? '';
- if (prevChild?.nodeType === Node.TEXT_NODE && prevText.endsWith('\n')) {
- const next = nextTextNode(prevChild);
- if (next) {
- range.setStart(next, 0);
- range.collapse(true);
- }
- }
- }
-};
-
-/**
- * Rebuild the browser selection from a tracked {@link Position} after a host
- * re-render. Recreates the `[position, position + extent]` range (collapsed when
- * `extent` is 0) and applies the newline-boundary nudge.
- *
- * When `position.backward` is set, the range is restored as a BACKWARD selection
- * — anchor at the range end, focus at the range start — by collapsing to the end
- * and extending back to the start. A range added via `Selection.addRange` is
- * always forward, so without this a backward Shift+Arrow selection would have its
- * focus flipped to the bottom end on every restore. Forward and collapsed
- * positions take the plain `addRange` path unchanged.
- */
-export const restoreSelection = (element: HTMLElement, position: Position): void => {
- const range = makeRange(element, position.position, position.position + position.extent);
- adjustCursorAtNewlineBoundary(range);
-
- if (position.backward && position.extent > 0) {
- const selection = window.getSelection();
- if (selection) {
- selection.removeAllRanges();
- // Anchor at the bottom (range end), then move the focus up to the range
- // start so the selection direction matches the user's Shift+Arrow.
- selection.collapse(range.endContainer, range.endOffset);
- selection.extend(range.startContainer, range.startOffset);
- return;
- }
- }
-
- setCurrentRange(range);
-};
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.test.ts b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
index 63f2cebd4..ffa18f989 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.test.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { renderHook, act } from '@testing-library/react';
-import type { Position } from './useEditable';
+import type { Position } from './editingTypes';
import { useSourceEditing, preloadSourceEditingEngine } from './useSourceEditing';
import { analyzeSource } from './SourceEditingEngine';
import type { Code, ControlledCode, VariantCode, SourceComments } from '../CodeHighlighter/types';
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.ts b/packages/docs-infra/src/useCode/useSourceEditing.ts
index 1c6f26bd4..4cc6c3498 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.ts
@@ -4,7 +4,7 @@ import type { Root as HastRoot } from 'hast';
// (via `useCopyFunctionality`/`Pre`). Passing it into the lazy editing engine
// keeps that engine chunk from statically pulling it (and `hastDecompress`).
import { stringOrHastToString } from '../pipeline/hastUtils';
-import type { Position } from './useEditable';
+import type { Position } from './editingTypes';
import type {
Code,
ControlledCode,
From febbf40b655da078266ca154a0ba67198072c342 Mon Sep 17 00:00:00 2001
From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:14:12 +0530
Subject: [PATCH 3/6] [docs-infra] Fix the browser test selector and emit once
per Tab
---
.../src/useCode/CodeEditor.test.tsx | 31 +++++++++++++++++++
.../docs-infra/src/useCode/CodeEditor.tsx | 15 ++++++++-
.../docs-infra/src/useCode/Pre.browser.tsx | 6 ++--
3 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/packages/docs-infra/src/useCode/CodeEditor.test.tsx b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
index b4e7cd032..90a1b2167 100644
--- a/packages/docs-infra/src/useCode/CodeEditor.test.tsx
+++ b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
@@ -104,6 +104,37 @@ describe('CodeEditor', () => {
expect(setSource).toHaveBeenCalledWith(' const a = 1;', 'App.tsx', expect.any(Object));
});
+ it('emits once per Tab, not twice', () => {
+ // jsdom has no `execCommand`, so stand one in that behaves like a browser's:
+ // it edits the value AND fires `input`. Without the guard the editor reports
+ // the same indent twice — once from that event, once from the key handler —
+ // and reparses it twice.
+ const setSource = vi.fn();
+ render( );
+ const element = textarea();
+
+ const execCommand = vi.fn((_command: string, _ui: boolean, text: string) => {
+ const { selectionStart, selectionEnd, value } = element;
+ element.value = `${value.slice(0, selectionStart)}${text}${value.slice(selectionEnd)}`;
+ fireEvent.input(element);
+ return true;
+ });
+ document.execCommand = execCommand as unknown as typeof document.execCommand;
+
+ try {
+ element.setSelectionRange(0, 0);
+ fireEvent.keyDown(element, { key: 'Tab' });
+
+ expect(execCommand).toHaveBeenCalledWith('insertText', false, ' ');
+ expect(element.value).toBe(' const a = 1;');
+ expect(setSource).toHaveBeenCalledTimes(1);
+ expect(setSource).toHaveBeenCalledWith(' const a = 1;', 'App.tsx', expect.any(Object));
+ } finally {
+ // @ts-expect-error -- restoring the jsdom default, which is absent
+ delete document.execCommand;
+ }
+ });
+
it('outdents on Shift+Tab', () => {
render( {}} />);
const element = textarea();
diff --git a/packages/docs-infra/src/useCode/CodeEditor.tsx b/packages/docs-infra/src/useCode/CodeEditor.tsx
index 56ccf2224..6e395e1d5 100644
--- a/packages/docs-infra/src/useCode/CodeEditor.tsx
+++ b/packages/docs-infra/src/useCode/CodeEditor.tsx
@@ -179,8 +179,16 @@ export function CodeEditor({
[setSource, fileName, language, parseSourceAsync],
);
+ // `execCommand` fires `input` synchronously, so a programmatic edit would
+ // otherwise emit twice — once here with the intermediate selection, once from
+ // the handler that applied it. Suppress this one and let the caller emit.
+ const applyingEditRef = React.useRef(false);
+
const handleInput = React.useCallback(
(event: React.FormEvent) => {
+ if (applyingEditRef.current) {
+ return;
+ }
const target = event.currentTarget;
emit(target.value, target.selectionStart, target.selectionEnd);
},
@@ -205,7 +213,12 @@ export function CodeEditor({
return;
}
event.preventDefault();
- replaceRange(textarea, edit.start, edit.end, edit.text);
+ applyingEditRef.current = true;
+ try {
+ replaceRange(textarea, edit.start, edit.end, edit.text);
+ } finally {
+ applyingEditRef.current = false;
+ }
textarea.setSelectionRange(edit.selectionStart, edit.selectionEnd);
emit(textarea.value, edit.selectionStart, edit.selectionEnd);
},
diff --git a/packages/docs-infra/src/useCode/Pre.browser.tsx b/packages/docs-infra/src/useCode/Pre.browser.tsx
index 1280b6a1a..ec8c6d89d 100644
--- a/packages/docs-infra/src/useCode/Pre.browser.tsx
+++ b/packages/docs-infra/src/useCode/Pre.browser.tsx
@@ -149,10 +149,12 @@ describe('Pre editing', () => {
textarea.setSelectionRange(INITIAL_SOURCE.length, INITIAL_SOURCE.length);
await userEvent.keyboard('\nconst tail = 1;');
- const painted = document.querySelector('pre[aria-hidden="true"]')!;
+ // The painted layer is ``'s own ``; the textarea only overlays it.
+ const painted = document.querySelector('.editable-code-wrapper pre')!;
await waitFor(() => expect(painted.textContent).toContain('const tail = 1;'));
- // Highlighting still applies to the edited text.
+ // Highlighting still applies to the edited text, and frames survive editing.
await waitFor(() => expect(painted.querySelector('[class*="pl-"]')).not.toBeNull());
+ expect(painted.querySelector('span.frame')).not.toBeNull();
});
it('does not mount a textarea for a read-only block', () => {
From 7f99e22b6b28bebf9a4347ef53917fb0471f9486 Mon Sep 17 00:00:00 2001
From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:22:20 +0530
Subject: [PATCH 4/6] Fix docs
---
docs/app/docs-infra/hooks/page.mdx | 4 +-
docs/app/docs-infra/hooks/use-code/types.md | 72 +++++++++++++++++++++
2 files changed, 75 insertions(+), 1 deletion(-)
diff --git a/docs/app/docs-infra/hooks/page.mdx b/docs/app/docs-infra/hooks/page.mdx
index 5736f8c75..eeca3964c 100644
--- a/docs/app/docs-infra/hooks/page.mdx
+++ b/docs/app/docs-infra/hooks/page.mdx
@@ -91,10 +91,12 @@ The `useCode` hook provides programmatic access to code display, editing, and tr
- File Navigation Issues
- Hash Behavior Not Working as Expected
- Exports:
+ - preloadCodeEditor
+ - Parameters: loader
- useCode
- Parameters: contentProps, opts
- useCodeComponents
-- Types: CodeComponentsContext, UseCodeOpts, UseCodeResult
+- Types: CodeComponentsContext, CodeEditorProps, UseCodeOpts, UseCodeResult
diff --git a/docs/app/docs-infra/hooks/use-code/types.md b/docs/app/docs-infra/hooks/use-code/types.md
index 84c769779..506f15b08 100644
--- a/docs/app/docs-infra/hooks/use-code/types.md
+++ b/docs/app/docs-infra/hooks/use-code/types.md
@@ -4,6 +4,22 @@
## API Reference
+### preloadCodeEditor
+
+Warms the editor chunk ahead of first focus. Fails open.
+
+**Parameters:**
+
+| Parameter | Type | Default | Description |
+| :-------- | :----------------- | :------ | :---------- |
+| loader? | `CodeEditorLoader` | - | - |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Promise;
+```
+
### useCode
**useCode Parameters:**
@@ -35,6 +51,40 @@ type ReturnValue = Partial | undefined;
type CodeComponentsContext = React.Context | undefined>;
```
+### CodeEditorProps
+
+A transparent textarea laid over an already-highlighted ``. The textarea
+owns the text, so selection, undo/redo, IME, and spellcheck stay native; the
+`` beneath it keeps painting, frames and all.
+
+Nothing is highlighted here. An edit goes out through `setSource`, the host
+re-parses, and the `` re-renders from the new tree — which is what keeps
+emphasis frames, collapse placeholders, and the intersection-driven frame
+hydration working while editing.
+
+Indent and outdent go through `document.execCommand('insertText')` rather than
+a direct value write, which is what keeps them on the browser's native undo
+stack. The `inputType` vocabulary used to classify edits follows the approach
+in Pierre's editor (https\://github.com/pierrecomputer/pierre).
+
+```typescript
+type CodeEditorProps = {
+ /** Complete source, matching the text painted by the `` underneath. */
+ source: string;
+ /** Canonical file name reported back through `setSource`. */
+ fileName?: string;
+ language?: string;
+ /** Spaces inserted by Tab. */
+ tabSize?: number;
+ setSource: SetSource;
+ /** Fired on first focus, so the host can warm the live runtime. */
+ onActivate?: () => void;
+ /** Fired on Escape, so the host can move focus out. */
+ onExit?: () => void;
+ onReady?: (textarea: HTMLTextAreaElement | null) => void;
+};
+```
+
### UseCodeOpts
```typescript
@@ -285,3 +335,25 @@ type SourceEnhancer = (
fileName: string,
) => { data?: unknown | undefined } | Promise;
```
+
+### SetSource
+
+```typescript
+type SetSource = (
+ source: string,
+ fileName?: string | undefined,
+ position?:
+ | {
+ position: number;
+ extent: number;
+ content: string;
+ line: number;
+ history?: 'undo' | 'redo' | undefined;
+ historyPivotLine?: number | undefined;
+ deletedFromLineStart?: boolean | undefined;
+ backward?: boolean | undefined;
+ }
+ | undefined,
+ preParsed?: Root | undefined,
+) => void;
+```
From 9cbefe576dbf4fc0a99610e328e34dd81fcfe3b9 Mon Sep 17 00:00:00 2001
From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com>
Date: Fri, 14 Aug 2026 18:27:30 +0530
Subject: [PATCH 5/6] [docs-infra] Keep the editor out of the pre's text
content
---
.../src/useCode/CodeEditor.test.tsx | 7 +++
.../docs-infra/src/useCode/CodeEditor.tsx | 22 ++++++---
.../docs-infra/src/useCode/Pre.browser.tsx | 48 +++++++++++++------
3 files changed, 56 insertions(+), 21 deletions(-)
diff --git a/packages/docs-infra/src/useCode/CodeEditor.test.tsx b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
index 90a1b2167..1879f9ddd 100644
--- a/packages/docs-infra/src/useCode/CodeEditor.test.tsx
+++ b/packages/docs-infra/src/useCode/CodeEditor.test.tsx
@@ -25,6 +25,13 @@ describe('CodeEditor', () => {
expect(textarea().value).toBe('const value = 1;');
});
+ it('keeps the source out of its own DOM subtree', () => {
+ // The textarea renders inside the ``, so a `defaultValue` would become
+ // a child text node and `pre.textContent` would report the source twice.
+ render( {}} />);
+ expect(textarea().textContent).toBe('');
+ });
+
it('reports edited source with the caret position', () => {
const setSource = vi.fn();
render( );
diff --git a/packages/docs-infra/src/useCode/CodeEditor.tsx b/packages/docs-infra/src/useCode/CodeEditor.tsx
index 6e395e1d5..09ea4aa30 100644
--- a/packages/docs-infra/src/useCode/CodeEditor.tsx
+++ b/packages/docs-infra/src/useCode/CodeEditor.tsx
@@ -127,8 +127,12 @@ export function CodeEditor({
textarea.style.top = `${code.offsetTop - pre.clientTop}px`;
textarea.style.left = `${code.offsetLeft - pre.clientLeft}px`;
- textarea.style.width = `${code.scrollWidth}px`;
- textarea.style.height = `${code.scrollHeight}px`;
+ // A zero measurement means the block has not been laid out yet (or is
+ // hidden). Collapsing the textarea to 0×0 would make it unclickable, and
+ // nothing would resize it back if the observer never fires again, so fall
+ // back to filling the ``.
+ textarea.style.width = code.scrollWidth > 0 ? `${code.scrollWidth}px` : '100%';
+ textarea.style.height = code.scrollHeight > 0 ? `${code.scrollHeight}px` : '100%';
};
sync();
@@ -138,9 +142,14 @@ export function CodeEditor({
return () => observer.disconnect();
}, [source]);
- // Adopt source that did not originate here — a reset, a transform swap, or a
- // file switch. An echo of our own last edit is ignored so the caret survives.
- React.useEffect(() => {
+ // Seeds the textarea and adopts source that did not originate here — a reset,
+ // a transform swap, or a file switch. An echo of our own last edit is ignored
+ // so the caret survives.
+ //
+ // The value is written imperatively rather than through `defaultValue`: the
+ // textarea lives inside the ``, and a default value would become a child
+ // text node, so `pre.textContent` would return the source twice.
+ React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
@@ -185,7 +194,7 @@ export function CodeEditor({
const applyingEditRef = React.useRef(false);
const handleInput = React.useCallback(
- (event: React.FormEvent) => {
+ (event: React.InputEvent) => {
if (applyingEditRef.current) {
return;
}
@@ -229,7 +238,6 @@ export function CodeEditor({