diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1920cc12f..428d15391 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -84,7 +84,7 @@ jobs:
test_benchmark:
executor:
name: code-infra/mui-node-browser
- playwright-img-version: 'v1.58.2-noble'
+ playwright-img-version: 'v1.59.1-noble'
resource_class: medium
steps:
- checkout
diff --git a/AGENTS.md b/AGENTS.md
index 7992294e9..d0a07942e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,6 +22,8 @@ Always reference these instructions first and fallback to search or bash command
- **Formatting**: `pnpm prettier` -- always run before pushing code.
- **Run tests**: `pnpm test --run` takes 5-10 seconds. **NEVER CANCEL**. Set timeout to 30+ minutes.
- **Run specific tests**: `pnpm test --run loadServerSource` or `pnpm test --run integration.test.ts` for targeted testing
+- **Run browser tests**: `pnpm test:browser --run` -- requires podman or docker. Starts a containerized Playwright server and runs browser tests against it.
+- **Run browser tests in CI**: In a `mcr.microsoft.com/playwright` container image, run `pnpm test:browser:unconfined` directly — no container engine needed. Only use in a CI environment.
- **ALWAYS use `--run` flag** to avoid watch mode when running tests programmatically
- **Do NOT use `--`** in test commands (e.g., avoid `pnpm test -- --run`)
- **Use VS Code Vitest extension** whenever possible for interactive test development and debugging
diff --git a/docs/app/bench/docs-infra/components/code-controller-context/demos/code/index.ts b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/index.ts
new file mode 100644
index 000000000..1ae19bf25
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/index.ts
@@ -0,0 +1,4 @@
+import { createDemoPerformance } from '@/functions/createDemoPerformance';
+import Page from './page';
+
+export const DemoCodeControllerContextPerformance = createDemoPerformance(import.meta.url, Page);
diff --git a/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx
new file mode 100644
index 000000000..6891f8544
--- /dev/null
+++ b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react';
+import { createParseSource } from '@mui/internal-docs-infra/pipeline/parseSource';
+import { CodeHighlighter } from '@mui/internal-docs-infra/CodeHighlighter';
+import { CodeProvider } from '@mui/internal-docs-infra/CodeProvider';
+import { CodeController } from '../../../../../../docs-infra/components/code-controller-context/demos/code-editor/CodeController';
+import { CodeEditorContent } from '../../../../../../docs-infra/components/code-controller-context/demos/code-editor/CodeEditorContent';
+
+import code from '../../../code-highlighter/snippets/large/snippet';
+
+const sourceParser = createParseSource();
+
+export default function Page() {
+ return (
+ // @focus-start
+
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 } = 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; + 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;
+ 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;
+ }
+ 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 } = setupHighlighted(html, { indentation: 2 });
+
+ // Place caret at end of the 2-space indent on line 2
+ // "aaa\n" = 4 chars, " " = 2 → offset 6
+ 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 } = 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.
+ 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);
+ });
+ }
+
+ // Firefox computes the default-action target of arrow keys from the
+ // selection at the time the keydown was queued, so when we restore the
+ // caret inside the keydown handler the native ArrowUp doesn't see it.
+ // The user-visible bug below still requires fixing for Chromium/WebKit
+ // (where the vast majority of editors run) — Firefox needs a separate
+ // workaround that's tracked outside this regression test.
+ const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent);
+
+ it.skipIf(isFirefox)(
+ '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
", + '` 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 };
+}
+
+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 if not already pre', () => {
+ const { element } = setup('hello');
+ expect(element.style.whiteSpace).toBe('pre-wrap');
+ });
+
+ 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', () => {
+ 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();
+
+ expect(element.style.whiteSpace).toBe('normal');
+ expect(element.contentEditable).toBe('false');
+ });
+
+ it('sets tabSize when indentation option is provided', () => {
+ const { element } = setup('hello', { indentation: 4 });
+ 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');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 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',
+ " Type Whatever You Want Below
",
+ ' Type Whatever You Want Below
x\n", + ); + expect(resultLines).toEqual([ + 'aaa', + "Type Whatever You Want Below
x", + '',
+ '',
+ '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 on the next line when fallback typing inserts after ', () => {
+ 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 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', '']);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 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 collapses the line and lands the caret on the previous 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), Backspace would normally
+ // delete one indent space and leave the caret in the clipped
+ // `[0, minColumn)` gutter. Instead we collapse the entire blank
+ // line so the caret lands at the end of `hello`.
+ 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);
+ expect(element.textContent).toBe('hello\n world');
+ 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'.length);
+
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Backspace', bubbles: true }));
+ const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
+ expect(text).toBe('hello\n world\n');
+ });
+
+ 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 } = 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 };
+ }
+
+ 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('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');
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 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 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);
+ });
+ });
+});
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
new file mode 100644
index 000000000..9aee30697
--- /dev/null
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -0,0 +1,1369 @@
+/*
+
+MIT License
+
+Copyright (c) 2020 Phil Plückthun,
+Copyright (c) 2021 Formidable
+Copyright (c) 2026 Material-UI SAS
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+*/
+
+// Forked from https://github.com/FormidableLabs/use-editable
+// Changes (see git history and inline comments for rationale):
+// - Linting, formatting, tests, and React 19 compatibility (lazy useState, useRef MutationObserver, SSR guards)
+// - Performance: TreeWalker-based makeRange/getPosition, deduped toString() calls, getLineInfo walks only neighboring lines
+// - Firefox quirks: preserve pendingContent across rapid keydowns, refresh baseline after controlled edits, repair line-merges, route plaintext keys through edit.insert in the contentEditable="true" fallback
+// - Undo stack: record repaired (not raw) content, allow tracking before first flush, bypass 500ms dedup for structural edits (Enter)
+// - Repeat-key flush debouncing so syntax re-highlight fires once on key release
+// - Resync (instead of block) on stale-DOM arrow keys so navigation isn't eaten after a pending edit
+// - adjustCursorAtNewlineBoundary applied to all programmatic caret placements; getState() returns an empty snapshot pre-mount
+// - New `minColumn` option: skip clipped indent gutter via arrow navigation, click, and tab-focus; Backspace on a fully-clipped blank line collapses the line
+// - New `minRow`/`maxRow`/`onBoundary` options: arrow navigation past the visible region invokes the callback (and falls through natively when provided so hosts can expand collapsed regions)
+// - New `caretSelector` option: synchronous horizontal line-wrap and post-arrow rAF snap to lift the caret out of inter-line gap text nodes (e.g. `\n` between `.line` spans)
+// - Override copy/cut: write `Range.toString()` for `text/plain` (avoids duplicated newlines from block-level line wrappers) and an inline-styled `` clone for `text/html`; strip the clipped indent gutter from both payloads when `minColumn` is set
+
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
+import {
+ type Position,
+ adjustCursorAtNewlineBoundary,
+ asElement,
+ getCurrentRange,
+ getLineInfo,
+ getOffsetAtLineColumn,
+ getPosition,
+ isPlaintextInputKey,
+ isUndoRedoKey,
+ makeRange,
+ repairUnexpectedLineMerge,
+ setCurrentRange,
+ toString,
+} from './useEditableUtils';
+import { cloneRangeWithInlineStyles } from './cloneRangeWithInlineStyles';
+import {
+ extractLeadingPerLine,
+ stripLeadingPerLine,
+ stripLeadingPerLineDom,
+} from './stripLeadingPerLine';
+
+export type { Position } from './useEditableUtils';
+
+type History = [Position, string];
+
+const observerSettings = {
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ subtree: true,
+};
+
+// Computed-style properties inlined onto each element in the copied
+// HTML fragment so external paste targets render with the same syntax
+// highlighting without needing our stylesheet.
+const CLIPBOARD_ELEMENT_STYLE_PROPS = [
+ 'color',
+ 'background-color',
+ 'font-weight',
+ 'font-style',
+ 'text-decoration',
+];
+
+// Properties inlined onto the wrapper so the pasted block keeps the
+// editable's typography even if only a descendant was selected.
+const CLIPBOARD_ROOT_STYLE_PROPS = [
+ 'font-family',
+ 'font-size',
+ 'line-height',
+ 'white-space',
+ 'background-color',
+ 'color',
+];
+
+// A small amount of padding + rounded corners gives the pasted snippet
+// a card-like appearance in rich-text targets without overriding the
+// background or font that consumers already control via the editable's
+// own styles.
+const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;';
+
+interface State {
+ disconnected: boolean;
+ onChange(text: string, position: Position, preParseResult?: unknown): void;
+ pendingContent: string | null;
+ queue: MutationRecord[];
+ history: History[];
+ historyAt: number;
+ position: Position | null;
+ /** setTimeout id used to debounce flushChanges() calls during key-repeat */
+ repeatFlushId: ReturnType | null;
+ /**
+ * AbortController for the in-flight `preParse` callback (if any). Reset
+ * on every new flush so a rapidly-typed sequence aborts stale parses
+ * before posting a fresh request.
+ */
+ preParseAbort: AbortController | null;
+ /**
+ * Set when an arrow-key handler invokes `onBoundary` (which typically
+ * triggers a host re-render to expand a collapsed region). The native
+ * arrow-key default action moves the caret AFTER our keydown handler
+ * returns, but the host's re-render commits BEFORE the resulting
+ * `selectionchange` updates `state.position`. Without this flag, the
+ * unconditional restore effect would snap the caret back to the stale
+ * pre-arrow `state.position` on that intermediate render. The flag is
+ * cleared after one skipped restore.
+ */
+ skipNextRestore: boolean;
+}
+
+export interface Options {
+ disabled?: boolean;
+ indentation?: number;
+ /**
+ * Minimum column the cursor is allowed to occupy on indented lines.
+ *
+ * When set, horizontal arrow navigation skips over the leading whitespace
+ * up to `minColumn` so the caret never lands inside a clipped/hidden
+ * indent region:
+ *
+ * - `ArrowLeft` at column `minColumn` (with that line's first `minColumn`
+ * characters all whitespace) jumps to the end of the previous line
+ * instead of stepping into the indent.
+ * - `ArrowRight` at the end of a line jumps to column `minColumn` of the
+ * next line (when the next line is indented at least that far) instead
+ * of landing at column 0.
+ *
+ * Useful when the editor is rendered in a horizontally-shifted view (for
+ * example a collapsed code block whose left padding is translated off
+ * screen) where columns below `minColumn` are not visible. Leave
+ * `undefined` for default arrow-key behavior.
+ */
+ minColumn?: number;
+ /**
+ * First row of the visible region. When set, `ArrowUp` on this row and
+ * `ArrowLeft` at the start of this row are blocked (no caret movement)
+ * and `onBoundary` is invoked. Useful when content above the visible
+ * region is hidden and the host wants a chance to reveal it.
+ */
+ minRow?: number;
+ /**
+ * Last row of the visible region. When set, `ArrowDown` on this row and
+ * `ArrowRight` at the end of this row are blocked (no caret movement)
+ * and `onBoundary` is invoked.
+ */
+ maxRow?: number;
+ /**
+ * Called when the user attempts to navigate past `minRow`/`maxRow` via
+ * arrow keys. When `onBoundary` is provided, the navigation is allowed
+ * to proceed natively so the host can react (e.g. expand a collapsed
+ * code block) and the caret continues moving in the now-visible
+ * content. When `onBoundary` is omitted, the navigation is blocked
+ * (caret stays put).
+ */
+ onBoundary?: () => void;
+ /**
+ * CSS selector identifying the elements that represent selectable
+ * "lines" inside the editable. When set, and only while the caret is
+ * actually inside an element matching the selector:
+ *
+ * - `ArrowLeft` at column 0 jumps synchronously to the end of the
+ * previous line.
+ * - `ArrowRight` at the end of a line jumps synchronously to the start
+ * of the next line.
+ *
+ * Useful when the editable contains intentionally-empty whitespace
+ * text nodes between block-level children (e.g. newline text nodes
+ * separating `.line` spans inside a `.frame`). Without this, the
+ * browser would place the caret in those gap nodes on horizontal
+ * navigation, making `ArrowLeft`/`ArrowRight` appear to no-op.
+ *
+ * Vertical navigation (`ArrowUp`/`ArrowDown`) is intentionally left to
+ * the browser so wrapped visual lines in `pre-wrap` layouts continue
+ * to behave natively. Gap nodes styled with `line-height: 0` are
+ * skipped by browsers vertically without intervention.
+ *
+ * The selector is matched against the caret's containing element via
+ * `Element.closest`, so non-`.line` render paths (e.g. plain-string
+ * editables) never trigger the wrap behavior.
+ */
+ caretSelector?: string;
+ /**
+ * Optional async pre-parse hook invoked before each `onChange` flush.
+ * When provided, the parser receives the post-edit `text` and caret
+ * `position` plus an `AbortSignal` that fires when a newer keystroke
+ * supersedes this flush. Its resolved value is forwarded as the third
+ * argument to `onChange`, allowing the host to cache an already-parsed
+ * HAST (or any other derived state) keyed off the same source string.
+ *
+ * If `preParse` is omitted, `onChange` runs synchronously inside the
+ * keyup / debounce handler as before. If it is provided, the React
+ * state sync is delayed until the returned promise settles. Structural
+ * edits that need a synchronous re-render (Enter, paste, cut, undo/redo,
+ * programmatic `edit.update`/`edit.insert`, `minColumn` blank-line
+ * collapse) bypass `preParse` and fire `onChange` immediately without
+ * a third argument.
+ */
+ preParse?: (text: string, position: Position, signal: AbortSignal) => Promise;
+}
+
+export interface Edit {
+ /** Replaces the entire content of the editable while adjusting the caret position. */
+ update(content: string): void;
+ /** Inserts new text at the caret position while deleting text in range of the offset (which accepts negative offsets). */
+ insert(append: string, offset?: number): void;
+ /** Positions the caret where specified */
+ move(pos: number | { row: number; column: number }): void;
+ /** Returns the current editor state, as usually received in onChange */
+ getState(): { text: string; position: Position };
+}
+
+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 closures (effects, the
+ // edit object, event handlers) can read `config.X` directly without
+ // any non-null assertions on `opts`.
+ const config: Options = opts ?? {};
+
+ const unblock = React.useState([])[1];
+ const state = React.useState(() => ({
+ disconnected: false,
+ onChange,
+ pendingContent: null,
+ queue: [],
+ history: [],
+ historyAt: -1,
+ position: null,
+ repeatFlushId: null,
+ skipNextRestore: false,
+ preParseAbort: null,
+ }))[0];
+
+ // MutationObserver is created once via useRef so it is never recreated on
+ // re-render and is not subject to React Strict Mode double-invocation of
+ // useState initializers (which would silently discard the first observer).
+ const observerRef = React.useRef(null);
+ if (observerRef.current === null && typeof MutationObserver !== 'undefined') {
+ observerRef.current = new MutationObserver((batch) => {
+ state.queue.push(...batch);
+ });
+ }
+
+ // The visible-region bounds (`minColumn`/`minRow`/`maxRow`/`onBoundary`)
+ // and `caretSelector` only affect handler logic, not the contentEditable
+ // setup itself. We mirror them in a ref so the handlers always read the
+ // latest values, while keeping these values out of the main effect's deps.
+ // Listing them as deps would tear down and re-bind contentEditable every
+ // time they change (e.g. when a host expands a collapsed code block),
+ // which causes the browser to drop focus mid-animation.
+ const boundsRef = React.useRef({
+ minColumn: config.minColumn,
+ minRow: config.minRow,
+ maxRow: config.maxRow,
+ onBoundary: config.onBoundary,
+ caretSelector: config.caretSelector,
+ preParse: config.preParse,
+ });
+ boundsRef.current.minColumn = config.minColumn;
+ boundsRef.current.minRow = config.minRow;
+ boundsRef.current.maxRow = config.maxRow;
+ boundsRef.current.onBoundary = config.onBoundary;
+ boundsRef.current.caretSelector = config.caretSelector;
+ boundsRef.current.preParse = config.preParse;
+
+ // useMemo with [] is a performance hint, not a semantic guarantee — React 19
+ // may discard the cache and recreate the object. useState with a lazy
+ // initializer is the correct primitive for a referentially stable object.
+ const [edit] = React.useState(() => ({
+ update(content: string) {
+ const { current: element } = elementRef;
+ if (element) {
+ const position = getPosition(element);
+ const prevContent = toString(element);
+ position.position += content.length - prevContent.length;
+ state.position = position;
+ state.onChange(content, position);
+ }
+ },
+ insert(append: string, deleteOffset?: number) {
+ const { current: element } = elementRef;
+ if (element) {
+ let range = getCurrentRange();
+ range.deleteContents();
+ range.collapse();
+ const position = getPosition(element);
+ const offset = deleteOffset || 0;
+ const start = position.position + (offset < 0 ? offset : 0);
+ const end = position.position + (offset > 0 ? offset : 0);
+ range = makeRange(element, start, end);
+ adjustCursorAtNewlineBoundary(range);
+ range.deleteContents();
+ if (append) {
+ range.insertNode(document.createTextNode(append));
+ }
+ const cursorRange = makeRange(element, start + append.length);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+ },
+ move(pos: number | { row: number; column: number }) {
+ const { current: element } = elementRef;
+ if (element) {
+ element.focus();
+ const position =
+ typeof pos === 'number' ? pos : getOffsetAtLineColumn(element, pos.row, pos.column);
+ const cursorRange = makeRange(element, position);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+ },
+ getState() {
+ const element = elementRef.current;
+ if (!element) {
+ // Pre-mount / unmounted: return an empty snapshot so callers
+ // that subscribe before the ref is attached get a stable shape.
+ return {
+ text: '',
+ position: { position: 0, extent: 0, content: '', line: 0 },
+ };
+ }
+ return { text: toString(element), position: getPosition(element) };
+ },
+ }));
+
+ React.useLayoutEffect(() => {
+ // Only for SSR / server-side logic
+ // typeof navigator check fails on Node.js 21+ which exposes navigator.userAgent;
+ // typeof window is the standard isomorphic SSR guard.
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ state.onChange = onChange;
+
+ if (!elementRef.current || config.disabled) {
+ return undefined;
+ }
+
+ state.disconnected = false;
+ observerRef.current?.observe(elementRef.current, observerSettings);
+ // Skip restoring the cursor while a key is held down. The debounced
+ // flushChanges hasn't run yet so state.position is stale; restoring it
+ // here would jump the cursor back on every incidental re-render (e.g.
+ // from an async enhancer setState). edit.insert() already placed the
+ // cursor correctly in the DOM — leave it there until the debounce fires.
+ //
+ // Also skip on the render right after an arrow-key boundary callback
+ // (see `state.skipNextRestore`): the native arrow movement hasn't
+ // applied yet, so `state.position` is the pre-arrow location and
+ // restoring it would visibly snap the caret back upward/downward.
+ if (state.skipNextRestore) {
+ state.skipNextRestore = false;
+ } else if (state.position && state.repeatFlushId === null) {
+ const { position, extent } = state.position;
+ const cursorRange = makeRange(elementRef.current, position, position + extent);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+
+ return () => {
+ observerRef.current?.disconnect();
+ };
+ });
+
+ React.useLayoutEffect(() => {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ if (!elementRef.current || config.disabled) {
+ state.history.length = 0;
+ state.historyAt = -1;
+ return undefined;
+ }
+
+ const element = elementRef.current;
+ if (!element) {
+ return undefined;
+ }
+ if (state.position) {
+ element.focus();
+ const { position, extent } = state.position;
+ const cursorRange = makeRange(element, position, position + extent);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+
+ const prevWhiteSpace = element.style.whiteSpace;
+ const prevContentEditable = element.contentEditable;
+ let hasPlaintextSupport = true;
+ try {
+ // Firefox and IE11 do not support plaintext-only mode
+ element.contentEditable = 'plaintext-only';
+ } catch (_error) {
+ element.contentEditable = 'true';
+ hasPlaintextSupport = false;
+ }
+
+ if (prevWhiteSpace !== 'pre') {
+ element.style.whiteSpace = 'pre-wrap';
+ }
+
+ if (config.indentation) {
+ const tabSizeValue = `${config.indentation}`;
+ element.style.setProperty('-moz-tab-size', tabSizeValue);
+ element.style.tabSize = tabSizeValue;
+ }
+
+ const indentPattern = `${' '.repeat(config.indentation || 0)}`;
+ const indentRe = new RegExp(`^(?:${indentPattern})`);
+ const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`);
+
+ let trackStateTimestamp: number;
+ const trackState = (
+ ignoreTimestamp?: boolean,
+ contentOverride?: string,
+ positionOverride?: Position,
+ ): string | null => {
+ // Require a live selection so getPosition() (which calls getRangeAt(0)) is safe.
+ // Using !state.position would block recording the initial state: state.position is
+ // only set by flushChanges() which runs on keyup — after the first edit. Switching
+ // to rangeCount === 0 lets the very first keydown snapshot the pre-edit content.
+ if (!elementRef.current || (window.getSelection()?.rangeCount ?? 0) === 0) {
+ return null;
+ }
+
+ // Callers may pass in already-computed (and possibly repaired) content so
+ // we don't re-read a buggy intermediate DOM. flushChanges uses this to
+ // record the repaired post-edit state instead of the merged DOM that
+ // Firefox/observer left behind.
+ const content = contentOverride ?? toString(element);
+ const position = positionOverride ?? getPosition(element);
+ const timestamp = new Date().valueOf();
+
+ // Prevent recording new state in list if last one has been new enough
+ const lastEntry = state.history[state.historyAt];
+ if (
+ (!ignoreTimestamp && timestamp - trackStateTimestamp < 500) ||
+ (lastEntry && lastEntry[1] === content)
+ ) {
+ trackStateTimestamp = timestamp;
+ return content;
+ }
+
+ state.historyAt += 1;
+ const at = state.historyAt;
+ state.history[at] = [position, content];
+ state.history.splice(at + 1);
+ if (at > 500) {
+ state.historyAt -= 1;
+ state.history.shift();
+ }
+ return content;
+ };
+
+ const disconnect = () => {
+ observerRef.current?.disconnect();
+ state.disconnected = true;
+ };
+
+ const flushChanges = (ignoreTimestamp?: boolean, bypassPreParse?: boolean) => {
+ const records = observerRef.current?.takeRecords() ?? [];
+ state.queue.push(...records);
+ const position = getPosition(element);
+ if (state.queue.length) {
+ // We DO NOT revert the queued mutations yet — letting them stay in
+ // the live DOM means the user's keystroke remains visible while
+ // `preParse` runs. The mutation queue is held until commit (below)
+ // so when React eventually re-renders the highlighted content, it
+ // first sees its expected previous DOM.
+ const content = repairUnexpectedLineMerge(
+ toString(element),
+ state.pendingContent,
+ position,
+ );
+ state.position = position;
+
+ // Record the REPAIRED content into history before notifying the app.
+ // Reading toString() back from the DOM here would capture the buggy
+ // pre-repair state (e.g. a Firefox line-merge), which is what was
+ // previously polluting the undo stack.
+ trackState(ignoreTimestamp, content, position);
+
+ // Snapshot the queue length representing mutations that belong to
+ // THIS flush. Anything appended past this index by the time
+ // `commit` runs is a straggler — a newer keystroke whose own
+ // keyup-triggered `flushChanges` will produce a fresher commit. In
+ // that case we must NOT revert the stragglers (or we'd lose the
+ // user's character) and we must NOT call `onChange` with our now
+ // stale `content` (or we'd briefly render the older state on top
+ // of the newer DOM).
+ const queueLengthAtFlush = state.queue.length;
+
+ // Commit phase: revert the queued mutations and hand control to
+ // React. The revert + React commit are bundled into a single task
+ // via `flushSync` so the browser cannot paint the briefly-reverted
+ // DOM between the two — the user's keystroke stays continuously on
+ // screen, transitioning directly from "raw mutation" to
+ // "highlighted React render".
+ const commit = (preParseResult?: unknown) => {
+ // Drain anything pending in the observer first so we have an
+ // accurate count of stragglers (mutations made after this
+ // flush started). The observer stays connected during the
+ // `preParse` await so additional keystrokes ARE captured but
+ // are NOT blocked by the `state.disconnected` guard in
+ // `onKeyDown`.
+ const stragglers = observerRef.current?.takeRecords() ?? [];
+ state.queue.push(...stragglers);
+ if (state.queue.length > queueLengthAtFlush) {
+ // A newer keystroke landed in the DOM after this flush
+ // started. Drop this commit on the floor — the straggler's
+ // own `flushChanges` (already running, or about to run on
+ // its keyup) will produce a fresher commit that reverts the
+ // entire combined mutation set and reports the up-to-date
+ // content. Leaving the observer connected and
+ // `state.disconnected` false lets onKeyDown keep accepting
+ // input in the meantime.
+ return;
+ }
+ disconnect();
+ while (state.queue.length > 0) {
+ const mutation = state.queue.pop();
+ if (!mutation) {
+ break;
+ }
+ if (mutation.oldValue !== null) {
+ mutation.target.textContent = mutation.oldValue;
+ }
+ for (let i = mutation.removedNodes.length - 1; i >= 0; i -= 1) {
+ mutation.target.insertBefore(mutation.removedNodes[i], mutation.nextSibling);
+ }
+ for (let i = mutation.addedNodes.length - 1; i >= 0; i -= 1) {
+ if (mutation.addedNodes[i].parentNode) {
+ mutation.target.removeChild(mutation.addedNodes[i]);
+ }
+ }
+ }
+ ReactDOM.flushSync(() => {
+ if (preParseResult === undefined) {
+ // Preserve the historical (text, position) calling convention
+ // for the sync / bypass path so consumers can distinguish a
+ // preParse-result-less commit from one whose result happened
+ // to be `undefined`.
+ state.onChange(content, position);
+ } else {
+ state.onChange(content, position, preParseResult);
+ }
+ });
+ };
+
+ const { preParse } = boundsRef.current;
+ if (preParse && !bypassPreParse) {
+ // Abort any prior in-flight preParse — only the most recent
+ // keystroke's parse result is worth waiting for.
+ if (state.preParseAbort) {
+ state.preParseAbort.abort();
+ }
+ const controller = new AbortController();
+ state.preParseAbort = controller;
+ const { signal } = controller;
+ preParse(content, position, signal).then(
+ (result) => {
+ if (signal.aborted) {
+ return;
+ }
+ if (state.preParseAbort === controller) {
+ state.preParseAbort = null;
+ }
+ commit(result);
+ },
+ () => {
+ if (state.preParseAbort === controller) {
+ state.preParseAbort = null;
+ }
+ if (signal.aborted) {
+ // Aborted by a newer keystroke — drop silently. The
+ // queued mutations stay in place until the superseding
+ // flush commits them.
+ return;
+ }
+ // Real parse failure (e.g. unknown grammar, worker error).
+ // Fall back to committing without a preParseResult so the
+ // source still propagates to onChange — matching the
+ // historical sync path's fail-open behavior. Without this,
+ // the DOM would show the user's typed text while controlled
+ // state stayed stale, and the next render would revert it.
+ commit();
+ },
+ );
+ } else {
+ // Structural / synchronous edit — bypass preParse so the React
+ // state sync happens on the same commit as the DOM change.
+ if (state.preParseAbort) {
+ state.preParseAbort.abort();
+ state.preParseAbort = null;
+ }
+ commit();
+ }
+ }
+
+ state.pendingContent = null;
+ };
+
+ // Snap a collapsed caret out of an inter-line gap text node (e.g. the
+ // literal `\n` between `.line` spans) onto the nearest `.line` in
+ // `direction`. Used by both the post-arrow rAF and the pointer
+ // handlers — clicks can land in gap nodes too. When `isVertical`, the
+ // caret lands at `preferredColumn` of the target line (clamped);
+ // otherwise it lands at the start (forward) or end (backward).
+ // Returns `true` when a snap was applied.
+ const snapCaretOutOfGapNode = (
+ direction: 'forward' | 'backward',
+ isVertical: boolean,
+ preferredColumn: number,
+ ): boolean => {
+ const { caretSelector } = boundsRef.current;
+ if (caretSelector === undefined) {
+ return false;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return false;
+ }
+ const snapRange = sel.getRangeAt(0);
+ if (!element.contains(snapRange.startContainer)) {
+ return false;
+ }
+ const startContainer = snapRange.startContainer;
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
+ // Caret is already inside a `.line` (or equivalent) — no snap needed.
+ if (startElement?.closest(caretSelector)) {
+ return false;
+ }
+ const lineEls = Array.from(element.querySelectorAll(caretSelector));
+ if (lineEls.length === 0) {
+ return false;
+ }
+ // Use document position to pick the right neighbour.
+ let target: Element | null = null;
+ if (direction === 'forward') {
+ for (let i = 0; i < lineEls.length; i += 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp < 0 means the caret is before this line.
+ if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line ahead — caret has landed past the last line. Snap back
+ // to the last line so the caret stays inside an editable row.
+ if (!target) {
+ target = lineEls[lineEls.length - 1];
+ }
+ } else {
+ for (let i = lineEls.length - 1; i >= 0; i -= 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp > 0 means the caret is after this line.
+ if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line behind — caret has landed before the first line.
+ if (!target) {
+ target = lineEls[0];
+ }
+ }
+ if (!target) {
+ return false;
+ }
+ const newRange = element.ownerDocument.createRange();
+ if (isVertical) {
+ // Walk the target line's text nodes to find the offset that
+ // matches `preferredColumn`, clamping to the line length.
+ const targetText = target.textContent ?? '';
+ const targetColumn = Math.min(preferredColumn, targetText.length);
+ let remaining = targetColumn;
+ const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
+ let placed = false;
+ let node = walker.nextNode();
+ while (node) {
+ const len = node.textContent?.length ?? 0;
+ if (remaining <= len) {
+ newRange.setStart(node, remaining);
+ newRange.collapse(true);
+ placed = true;
+ break;
+ }
+ remaining -= len;
+ node = walker.nextNode();
+ }
+ if (!placed) {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ } else if (direction === 'forward') {
+ newRange.selectNodeContents(target);
+ newRange.collapse(true);
+ } else {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ sel.removeAllRanges();
+ sel.addRange(newRange);
+ return true;
+ };
+
+ // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
+ // when the user clicks there. The arrow-key handler already prevents
+ // landing inside the gutter via keyboard navigation; this covers
+ // pointer-driven clicks. Range selections are left alone — clamping the
+ // anchor of a drag would feel surprising mid-gesture.
+ const snapCaretOutOfGutter = () => {
+ const { minColumn } = boundsRef.current;
+ if (minColumn === undefined || minColumn <= 0) {
+ return;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return;
+ }
+ const range = sel.getRangeAt(0);
+ if (!element.contains(range.startContainer)) {
+ return;
+ }
+ const position = getPosition(element);
+ if (position.content.length >= minColumn) {
+ return;
+ }
+ // Only snap when the gutter is actually whitespace — otherwise the
+ // line is shorter than `minColumn` and there's nowhere to snap to.
+ // `getLineInfo` walks just enough text nodes to read the current
+ // line; avoids materializing the full document text on every click.
+ const lineText = getLineInfo(element, position.line).currentLine;
+ if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
+ return;
+ }
+ edit.move({ row: position.line, column: minColumn });
+ };
+
+ const onKeyDown = (event: HTMLElementEventMap['keydown']) => {
+ if (event.defaultPrevented || event.target !== element) {
+ return;
+ }
+ if (state.disconnected) {
+ // React Quirk: between flushChanges() (which calls disconnect() and
+ // rewinds the DOM back to the pre-edit content) and React's commit
+ // (which re-observes via useLayoutEffect and restores state.position),
+ // an event can fire that we'd otherwise mishandle.
+ //
+ // For NAVIGATION keys (arrows) the DOM revert is irrelevant — the
+ // browser only needs a valid caret position to compute the next
+ // selection — so resync inline (restore caret + re-observe) and let
+ // the event proceed. Otherwise the keystroke would be eaten and the
+ // user would lose, for example, an ArrowUp step after Enter inside
+ // a focus frame. We deliberately do NOT include Home/End/PageUp/
+ // PageDown here: they would also need to compensate for the pending
+ // rerender (matching the arrow-key skip-next-restore handling) and
+ // currently lack that coverage, so keep them on the safe path.
+ //
+ // For EDITING keys (printable text, Enter, Tab, Backspace, Delete,
+ // …) we must NOT fall through: the live DOM is the reverted
+ // pre-edit snapshot, so applying a second edit on top would target
+ // the wrong text and corrupt content. Keep the original block-and-
+ // unblock behavior for those keys — React will commit the queued
+ // onChange momentarily and the user can re-issue the keystroke.
+ const isArrowKey =
+ event.key === 'ArrowLeft' ||
+ event.key === 'ArrowRight' ||
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowDown';
+ if (!isArrowKey) {
+ event.preventDefault();
+ unblock([]);
+ return;
+ }
+ if (state.position && state.repeatFlushId === null) {
+ const { position, extent } = state.position;
+ const cursorRange = makeRange(element, position, position + extent);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+ observerRef.current?.observe(element, observerSettings);
+ state.disconnected = false;
+ // The `unblock([])` below schedules a React rerender. If that
+ // rerender's restore effect runs before the native arrow movement
+ // has updated `state.position` (which happens asynchronously via
+ // `selectionchange`), the restore would snap the caret back to the
+ // stale pre-arrow position. In practice `selectionchange` usually
+ // fires first so the restore is a no-op, but arming the skip flag
+ // makes the fast path race-free regardless of scheduling. The
+ // boundary-movement branches arm the same flag for the same reason.
+ state.skipNextRestore = true;
+ unblock([]);
+ // Fall through and let this arrow event be handled normally
+ // with the restored caret position.
+ }
+
+ if (isUndoRedoKey(event)) {
+ event.preventDefault();
+
+ let history: History;
+ if (!event.shiftKey) {
+ state.historyAt -= 1;
+ const at = state.historyAt;
+ history = state.history[at];
+ if (!history) {
+ state.historyAt = 0;
+ }
+ } else {
+ state.historyAt += 1;
+ const at = state.historyAt;
+ history = state.history[at];
+ if (!history) {
+ state.historyAt = state.history.length - 1;
+ }
+ }
+
+ if (history) {
+ disconnect();
+ state.position = history[0];
+ state.onChange(history[1], history[0]);
+ }
+ return;
+ }
+
+ // Only capture the pre-edit snapshot when no edit is currently pending
+ // (i.e. the previous keystroke has already been flushed on keyup).
+ // Overwriting pendingContent on a rapid second keydown — whether the
+ // same key repeating OR a different key pressed before the first
+ // keyup — would lose the baseline that repairUnexpectedLineMerge
+ // needs to detect Firefox's line-merge quirk. The DOM may already
+ // contain a merged state when the second keydown fires; treating that
+ // as "previous" content makes the line-loss invisible.
+ if (state.pendingContent === null) {
+ state.pendingContent = trackState() ?? toString(element);
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ // Firefox Quirk: Since plaintext-only is unsupported we must
+ // ensure that only newline characters are inserted
+ const position = getPosition(element);
+ // We also get the current line and preserve indentation for the next
+ // line that's created
+ const match = /\S/g.exec(position.content);
+ const index = match ? match.index : position.content.length;
+ const text = `\n${position.content.slice(0, index)}`;
+ edit.insert(text);
+ } else if (!hasPlaintextSupport && !event.isComposing && isPlaintextInputKey(event)) {
+ // Firefox Quirk: native typing in contentEditable="true" can insert
+ // directly into the frame wrapper before the current line span.
+ // Route plain text input through the controlled insert path instead.
+ event.preventDefault();
+ edit.insert(event.key);
+ } else if ((!hasPlaintextSupport || config.indentation) && event.key === 'Backspace') {
+ // Firefox Quirk: Since plaintext-only is unsupported we must
+ // ensure that only a single character is deleted
+ event.preventDefault();
+ const range = getCurrentRange();
+ if (!range.collapsed) {
+ edit.insert('', 0);
+ } else {
+ const position = getPosition(element);
+ const { minColumn } = boundsRef.current;
+ // When the caret sits at `minColumn` on a blank (whitespace-only)
+ // line inside a clipped indent gutter, a normal Backspace would
+ // step into `[0, minColumn)` — visually invisible to the user
+ // since that range is hidden by the host. The user has nothing
+ // useful to delete on this line, so collapse the entire blank
+ // line and land the caret at the end of the previous line. This
+ // matches the mental model: "Backspace from an empty indented
+ // line removes the line."
+ //
+ // Walk only enough text nodes to read the current line — we
+ // don't need the rest of the document on every Backspace.
+ const couldCollapse =
+ minColumn !== undefined &&
+ minColumn > 0 &&
+ position.line > 0 &&
+ position.content.length === minColumn &&
+ /^\s*$/.test(position.content);
+ if (couldCollapse && minColumn !== undefined) {
+ // The redundant `minColumn !== undefined` check pins TS's
+ // narrowing across the boundary so we can use `minColumn`
+ // as a number directly without an assertion.
+ const fullLine = getLineInfo(element, position.line).currentLine;
+ if (fullLine.length === minColumn && /^\s*$/.test(fullLine)) {
+ edit.insert('', -(minColumn + 1));
+ return;
+ }
+ }
+ const match = blanklineRe.exec(position.content);
+ edit.insert('', match ? -match[1].length : -1);
+ }
+ } else if (config.indentation && event.key === 'Tab') {
+ event.preventDefault();
+ const position = getPosition(element);
+ const start = position.position - position.content.length;
+ const content = toString(element);
+ const newContent = event.shiftKey
+ ? content.slice(0, start) +
+ position.content.replace(indentRe, '') +
+ content.slice(start + position.content.length)
+ : content.slice(0, start) +
+ (config.indentation ? ' '.repeat(config.indentation) : '\t') +
+ content.slice(start);
+ edit.update(newContent);
+ } else if (
+ (boundsRef.current.minColumn !== undefined ||
+ boundsRef.current.minRow !== undefined ||
+ boundsRef.current.maxRow !== undefined ||
+ boundsRef.current.caretSelector !== undefined) &&
+ !event.shiftKey &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ (event.key === 'ArrowLeft' ||
+ event.key === 'ArrowRight' ||
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowDown')
+ ) {
+ // Arrow-key navigation that respects the visible region:
+ // - `minColumn`: skip over hidden/clipped leading indent so the
+ // caret never lands before `minColumn` via horizontal navigation.
+ // - `minRow`/`maxRow`: block navigation past the visible row range
+ // and invoke `onBoundary` so the host can react (e.g. expand).
+ // - `caretSelector`: when set, the editable contains non-selectable
+ // gap text nodes between lines; handle horizontal line-wrap
+ // ourselves so `ArrowLeft` at column 0 lands at the end of the
+ // previous line synchronously (without flashing through the gap).
+ // Only acts on a collapsed selection — let the browser handle range
+ // expansion when a modifier is held or text is already selected.
+ const range = getCurrentRange();
+ if (range.collapsed) {
+ const { minColumn, minRow, maxRow, onBoundary, caretSelector } = boundsRef.current;
+ const position = getPosition(element);
+ const column = position.content.length;
+ // Walk just enough of the document to gather the current line
+ // and its immediate neighbors instead of allocating the entire
+ // document string and a full per-line array on every keypress.
+ const {
+ currentLine: lineText,
+ prevLine,
+ nextLine,
+ hasNextLine,
+ } = getLineInfo(element, position.line);
+ const lineIsIndented =
+ minColumn !== undefined &&
+ lineText.length >= minColumn &&
+ /^\s*$/.test(lineText.slice(0, minColumn));
+ const atVisibleStart = minRow !== undefined && position.line === minRow;
+ const atVisibleEnd = maxRow !== undefined && position.line === maxRow;
+ const atLineStart =
+ column === 0 || (lineIsIndented && minColumn !== undefined && column === minColumn);
+ const atLineEnd = column === lineText.length;
+
+ // For caretSelector wrap, also confirm the caret is currently
+ // *inside* an element matching the selector. This keeps the wrap
+ // scoped to render paths that actually have inter-line gap nodes
+ // (e.g. highlighted `.line` spans) and leaves plain-text editables
+ // — where the browser handles arrows fine — untouched.
+ const caretInLine =
+ caretSelector !== undefined &&
+ (() => {
+ const startContainer = range.startContainer;
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
+ return !!startElement?.closest(caretSelector);
+ })();
+
+ // Helper: place the caret on a target line, clamping the column
+ // to the line's length and respecting `minColumn` indent. Used
+ // when we need to move synchronously across the inter-line gap
+ // text nodes that `caretSelector`-rendered content places between
+ // `.line` spans (a native arrow press would otherwise drop the
+ // caret *in* the gap). The caller passes the target line's text
+ // (already in hand from `getLineInfo`) so we don't re-walk the
+ // document.
+ const moveToLine = (targetRow: number, targetLine: string, desiredColumn: number) => {
+ let targetColumn = Math.min(desiredColumn, targetLine.length);
+ if (
+ minColumn !== undefined &&
+ targetLine.length >= minColumn &&
+ /^\s*$/.test(targetLine.slice(0, minColumn)) &&
+ targetColumn < minColumn
+ ) {
+ targetColumn = minColumn;
+ }
+ edit.move({ row: targetRow, column: targetColumn });
+ };
+
+ if (event.key === 'ArrowUp') {
+ if (atVisibleStart) {
+ if (caretInLine && position.line > 0) {
+ // Synchronously move the caret onto the previous `.line`
+ // before notifying the host. Without this, native ArrowUp
+ // can drop the caret into the inter-line gap text node
+ // (e.g. the literal `\n` between `.line` spans), trapping
+ // it in the "between lines" area after the host expands.
+ event.preventDefault();
+ moveToLine(position.line - 1, prevLine, column);
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
+ } else if (onBoundary) {
+ // Allow native caret movement so the host can scroll the
+ // newly-revealed content into view alongside the caret.
+ state.skipNextRestore = true;
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ }
+ } else if (event.key === 'ArrowDown') {
+ if (atVisibleEnd) {
+ if (caretInLine && hasNextLine) {
+ event.preventDefault();
+ moveToLine(position.line + 1, nextLine, column);
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
+ } else if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ }
+ } else if (event.key === 'ArrowLeft') {
+ if (atVisibleStart && atLineStart) {
+ if (caretInLine && position.line > 0) {
+ event.preventDefault();
+ edit.move({ row: position.line - 1, column: prevLine.length });
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
+ } else if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ } else if (
+ lineIsIndented &&
+ minColumn !== undefined &&
+ column === minColumn &&
+ position.line > 0
+ ) {
+ event.preventDefault();
+ edit.move({ row: position.line - 1, column: prevLine.length });
+ } else if (caretInLine && column === 0 && position.line > 0) {
+ // With non-selectable gaps between lines the browser would
+ // place the caret *in* the gap text node — making ArrowLeft
+ // a no-op. Jump synchronously to the end of the previous
+ // line instead.
+ event.preventDefault();
+ edit.move({ row: position.line - 1, column: prevLine.length });
+ }
+ } else if (event.key === 'ArrowRight') {
+ if (atVisibleEnd && atLineEnd) {
+ if (caretInLine && hasNextLine) {
+ event.preventDefault();
+ moveToLine(position.line + 1, nextLine, 0);
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
+ } else if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ } else if (minColumn !== undefined && column === lineText.length && hasNextLine) {
+ const nextIsIndented =
+ nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
+ if (nextIsIndented) {
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: minColumn });
+ } else if (caretInLine) {
+ // Same gap-flash avoidance as ArrowLeft: jump to start of
+ // next line synchronously.
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: 0 });
+ }
+ } else if (caretInLine && atLineEnd && hasNextLine) {
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: 0 });
+ }
+ }
+ }
+
+ // Schedule a post-arrow snap when `caretSelector` is set: the
+ // browser's native arrow handling can drop the caret into the
+ // non-selectable gap text nodes (e.g. the literal `\n` between
+ // `.line` spans, especially after pressing Down on the last line
+ // or Up on the first line). After the default action runs, if the
+ // caret is no longer inside a matching element, jump it to the
+ // nearest `.line` in the direction of travel so the caret never
+ // gets stuck "between lines".
+ const { caretSelector } = boundsRef.current;
+ if (caretSelector !== undefined && !event.defaultPrevented) {
+ const direction =
+ event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 'forward' : 'backward';
+ // For vertical arrows, capture the column the user is leaving
+ // *before* the browser moves the caret, so we can land on the
+ // same column of the target line if a snap is needed. Horizontal
+ // arrows always snap to start/end of the adjacent line.
+ const isVertical = event.key === 'ArrowUp' || event.key === 'ArrowDown';
+ let preferredColumn = 0;
+ if (isVertical) {
+ const preSel = element.ownerDocument.defaultView?.getSelection();
+ if (preSel && preSel.rangeCount > 0 && preSel.isCollapsed) {
+ const preRange = preSel.getRangeAt(0);
+ if (element.contains(preRange.startContainer)) {
+ preferredColumn = getPosition(element).content.length;
+ }
+ }
+ }
+ // requestAnimationFrame fires after the browser has applied the
+ // native caret movement but before paint, so the snap is invisible.
+ window.requestAnimationFrame(() => {
+ snapCaretOutOfGapNode(direction, isVertical, preferredColumn);
+ });
+ }
+ }
+
+ // After a controlled edit in plaintext-only contentEditable, the DOM is
+ // in a known-good post-edit state. Refresh pendingContent to that state
+ // so any subsequent native input within the same key burst — e.g.
+ // holding Enter then pressing x in plaintext-only contentEditable, where
+ // `x` falls through to native browser handling and may merge frame
+ // boundary lines — is measured against the correct baseline. Without
+ // this, repairUnexpectedLineMerge sees Enter add a line and the native
+ // merge remove a line for a net zero delta and short-circuits, leaving
+ // the merge unrepaired.
+ //
+ // We gate on `hasPlaintextSupport` because in the Firefox fallback
+ // (contenteditable=true) `edit.insert` itself can trigger the line-merge
+ // quirk, so toString() after it would already be buggy and we must keep
+ // the pre-edit baseline.
+ if (event.defaultPrevented && hasPlaintextSupport) {
+ state.pendingContent = toString(element);
+ }
+
+ // Flush changes as a key is held so the app can catch up.
+ // Debounce: reset the timer on each repeat keydown so the expensive
+ // onChange (syntax re-highlight) only fires once the user pauses typing.
+ // edit.insert() already updated the DOM so the cursor and text are live.
+ if (event.repeat) {
+ if (state.repeatFlushId !== null) {
+ clearTimeout(state.repeatFlushId);
+ }
+ state.repeatFlushId = setTimeout(() => {
+ state.repeatFlushId = null;
+ flushChanges();
+ }, 100);
+ }
+ };
+
+ const onKeyUp = (event: HTMLElementEventMap['keyup']) => {
+ if (event.defaultPrevented || event.isComposing) {
+ return;
+ }
+ // Cancel any pending debounced flush so keyup always flushes immediately
+ if (state.repeatFlushId !== null) {
+ clearTimeout(state.repeatFlushId);
+ state.repeatFlushId = null;
+ }
+ // Structural edits (Enter) must always create their own undo checkpoint.
+ // Regular character typing uses the 500ms dedup so you undo a word at a
+ // time, but each Enter should be individually undoable. flushChanges
+ // records the (repaired) post-edit content into history before firing
+ // onChange, so we don't poison the undo stack with intermediate
+ // browser-merged DOM states. Enter also forces a synchronous React
+ // state sync (bypassing `preParse`) so newlines render immediately.
+ if (!isUndoRedoKey(event)) {
+ flushChanges(event.key === 'Enter', event.key === 'Enter');
+ } else {
+ flushChanges();
+ }
+ // Chrome Quirk: The contenteditable may lose focus after the first edit or so
+ element.focus();
+ };
+
+ const onSelect = (event: Event) => {
+ // Chrome Quirk: The contenteditable may lose its selection immediately on first focus
+ const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
+ state.position = hasRange && event.target === element ? getPosition(element) : null;
+ };
+
+ const onPaste = (event: HTMLElementEventMap['paste']) => {
+ event.preventDefault();
+ const clipboard = event.clipboardData;
+ if (!clipboard) {
+ return;
+ }
+ state.pendingContent = trackState(true) ?? toString(element);
+ edit.insert(clipboard.getData('text/plain'));
+ // Paste replaces a chunk of source — flush synchronously so the
+ // pasted text highlights on the same commit instead of after a
+ // worker round-trip.
+ flushChanges(true, true);
+ };
+
+ // When the editable wraps lines in block-level elements (e.g. `.line`
+ // spans separated by literal `\n` gap text nodes), the browser's
+ // default HTML→text/plain serializer inserts an implicit newline
+ // between each block element on top of the explicit `\n` already
+ // present in the DOM, producing duplicated newlines in the
+ // clipboard. Override copy/cut to write `Range.toString()` for
+ // `text/plain` while still preserving the HTML payload (so pasting
+ // into rich-text targets keeps syntax highlighting).
+ const onCopyOrCut = (event: ClipboardEvent) => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !event.clipboardData) {
+ return;
+ }
+ const range = selection.getRangeAt(0);
+ if (range.collapsed || !element.contains(range.commonAncestorContainer)) {
+ return;
+ }
+ event.preventDefault();
+ const minColumn = boundsRef.current.minColumn;
+ // When the selection starts mid-gutter (e.g. minColumn=4 but the
+ // user dragged from column 2), only the gutter portion *inside*
+ // the selection should be stripped from the first line. Subsequent
+ // lines always start at column 0 of the document, so they get the
+ // full `minColumn` budget.
+ let firstLineStrip = 0;
+ const restStrip = minColumn ?? 0;
+ if (minColumn !== undefined && minColumn > 0) {
+ const beforeRange = element.ownerDocument.createRange();
+ beforeRange.setStart(element, 0);
+ beforeRange.setEnd(range.startContainer, range.startOffset);
+ const beforeText = beforeRange.toString();
+ const lastNewline = beforeText.lastIndexOf('\n');
+ const startColumn = beforeText.length - (lastNewline + 1);
+ firstLineStrip = Math.max(0, minColumn - startColumn);
+ }
+
+ // The caret-navigation guard already treats `[0, minColumn)` as a
+ // clipped indent gutter. Strip up to that many leading whitespace
+ // characters per line from the clipboard so the pasted snippet
+ // matches what the user sees rather than including indent that
+ // is hidden in the editable.
+ const plainText =
+ restStrip > 0
+ ? stripLeadingPerLine(range.toString(), firstLineStrip, restStrip)
+ : range.toString();
+ event.clipboardData.setData('text/plain', plainText);
+
+ const container = cloneRangeWithInlineStyles(element, range, {
+ elementStyleProps: CLIPBOARD_ELEMENT_STYLE_PROPS,
+ rootStyleProps: CLIPBOARD_ROOT_STYLE_PROPS,
+ rootStaticStyles: CLIPBOARD_ROOT_STATIC_STYLES,
+ });
+ if (restStrip > 0) {
+ stripLeadingPerLineDom(container, firstLineStrip, restStrip);
+ }
+ event.clipboardData.setData('text/html', container.outerHTML);
+
+ if (event.type === 'cut') {
+ // Mirror the paste path: capture pre-edit state for history, then
+ // delete the selection. When `minColumn` clipped the leading
+ // gutter whitespace out of the clipboard, re-insert exactly
+ // those characters at the selection location so cut stays
+ // lossless — the document keeps the hidden indent that the user
+ // could not see and never copied.
+ state.pendingContent = trackState(true) ?? toString(element);
+ const replacement =
+ restStrip > 0 ? extractLeadingPerLine(range.toString(), firstLineStrip, restStrip) : '';
+ edit.insert(replacement);
+ // Cut also bypasses preParse so the resulting document re-renders
+ // synchronously alongside the clipboard write.
+ flushChanges(true, true);
+ }
+ };
+
+ const onMouseUp = () => {
+ // First lift the caret out of any inter-line gap node so the
+ // gutter check below can see a real line position.
+ snapCaretOutOfGapNode('forward', false, 0);
+ snapCaretOutOfGutter();
+ };
+
+ // Tabbing into the editor places the caret at column 0 of the first
+ // line, which lands inside the clipped indent gutter. Browsers set the
+ // initial selection asynchronously after `focus`, so defer the snap.
+ const onFocus = () => {
+ const view = element.ownerDocument.defaultView;
+ if (!view) {
+ return;
+ }
+ view.requestAnimationFrame(() => {
+ snapCaretOutOfGapNode('forward', false, 0);
+ snapCaretOutOfGutter();
+ });
+ };
+
+ document.addEventListener('selectstart', onSelect);
+ window.addEventListener('keydown', onKeyDown);
+ element.addEventListener('paste', onPaste);
+ element.addEventListener('copy', onCopyOrCut);
+ element.addEventListener('cut', onCopyOrCut);
+ element.addEventListener('keyup', onKeyUp);
+ element.addEventListener('mouseup', onMouseUp);
+ element.addEventListener('focus', onFocus);
+
+ return () => {
+ if (state.repeatFlushId !== null) {
+ clearTimeout(state.repeatFlushId);
+ state.repeatFlushId = null;
+ }
+ // Abort any in-flight preParse so its eventual `onChange` doesn't
+ // fire after the editable has been torn down or toggled disabled.
+ if (state.preParseAbort) {
+ state.preParseAbort.abort();
+ state.preParseAbort = null;
+ }
+ document.removeEventListener('selectstart', onSelect);
+ window.removeEventListener('keydown', onKeyDown);
+ element.removeEventListener('paste', onPaste);
+ element.removeEventListener('copy', onCopyOrCut);
+ element.removeEventListener('cut', onCopyOrCut);
+ element.removeEventListener('keyup', onKeyUp);
+ element.removeEventListener('mouseup', onMouseUp);
+ element.removeEventListener('focus', onFocus);
+ element.style.whiteSpace = prevWhiteSpace;
+ element.contentEditable = prevContentEditable;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [elementRef.current, opts?.disabled, opts?.indentation]);
+
+ return edit;
+};
diff --git a/packages/docs-infra/src/useCode/useEditableUtils.ts b/packages/docs-infra/src/useCode/useEditableUtils.ts
new file mode 100644
index 000000000..d29337019
--- /dev/null
+++ b/packages/docs-infra/src/useCode/useEditableUtils.ts
@@ -0,0 +1,430 @@
+/*
+ * 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;
+}
+
+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);
+ }
+ }
+ }
+};
diff --git a/packages/docs-infra/src/useCode/useFileNavigation.test.ts b/packages/docs-infra/src/useCode/useFileNavigation.test.ts
index ebe0fa0b7..15bc810e4 100644
--- a/packages/docs-infra/src/useCode/useFileNavigation.test.ts
+++ b/packages/docs-infra/src/useCode/useFileNavigation.test.ts
@@ -3571,7 +3571,7 @@ describe('useFileNavigation', () => {
);
});
- it('should handle preClassName and preRef props with enhancers', async () => {
+ it('should handle preClassName and setSource props with enhancers', async () => {
const hastSource = {
type: 'root' as const,
children: [{ type: 'text' as const, value: 'const x = 1;' }],
@@ -3584,7 +3584,7 @@ describe('useFileNavigation', () => {
const mockEnhancer = vi.fn((root) => root);
const enhancers = [mockEnhancer];
- const mockRef = { current: null };
+ const mockSetSource = vi.fn();
const { result } = renderHook(() =>
useFileNavigation({
@@ -3595,7 +3595,7 @@ describe('useFileNavigation', () => {
variantKeys: ['Default'],
shouldHighlight: true,
preClassName: 'custom-class',
- preRef: mockRef,
+ setSource: mockSetSource,
sourceEnhancers: enhancers,
}),
);
diff --git a/packages/docs-infra/src/useCode/useFileNavigation.tsx b/packages/docs-infra/src/useCode/useFileNavigation.tsx
index 585e8d7b3..7f3681ca1 100644
--- a/packages/docs-infra/src/useCode/useFileNavigation.tsx
+++ b/packages/docs-infra/src/useCode/useFileNavigation.tsx
@@ -12,6 +12,7 @@ import { useUrlHashState } from '../useUrlHashState';
import { countLines } from '../pipeline/parseSource/addLineGutters';
import { getLanguageFromExtension } from '../pipeline/loaderUtils/getLanguageFromExtension';
import type { TransformedFiles } from './useCodeUtils';
+import type { SetSource } from './useSourceEditing';
import { Pre } from './Pre';
import { useSourceEnhancing } from './useSourceEnhancing';
import { toKebabCase } from '../pipeline/loaderUtils/toKebabCase';
@@ -101,7 +102,7 @@ interface UseFileNavigationProps {
variantKeys?: string[];
shouldHighlight: boolean;
preClassName?: string;
- preRef?: React.Ref;
+ setSource?: SetSource;
effectiveCode?: Code;
selectVariant?: React.Dispatch>;
fileHashMode?: 'remove-hash' | 'remove-filename';
@@ -113,6 +114,16 @@ interface UseFileNavigationProps {
* Enhancers receive the HAST root, comments extracted from source, and filename.
*/
sourceEnhancers?: SourceEnhancers;
+ /**
+ * Whether the surrounding code block is currently expanded. Forwarded to
+ * `` so it can disable collapsed-state behaviors (e.g. `minColumn`).
+ */
+ expanded?: boolean;
+ /**
+ * Called when the user attempts to navigate the caret past the visible
+ * region of a collapsed code block. Forwarded to ``.
+ */
+ expand?: () => void;
}
export interface UseFileNavigationResult {
@@ -137,7 +148,7 @@ export function useFileNavigation({
variantKeys = [],
shouldHighlight,
preClassName,
- preRef,
+ setSource,
effectiveCode,
selectVariant,
fileHashMode = 'remove-hash',
@@ -145,6 +156,8 @@ export function useFileNavigation({
saveVariantToLocalStorage,
hashVariant,
sourceEnhancers,
+ expanded,
+ expand,
}: UseFileNavigationProps): UseFileNavigationResult {
// Keep selectedFileName as untransformed filename for internal tracking
const [selectedFileNameInternal, setSelectedFileNameInternal] = React.useState<
@@ -494,6 +507,7 @@ export function useFileNavigation({
const language = isMainFile
? selectedVariant.language
: getLanguageFromFileName(selectedFileNameInternal);
+ const fileName = selectedFileNameInternal || selectedVariant.fileName;
const fileSlug = generateFileSlug(
mainSlug,
selectedFileNameInternal ?? selectedVariant.fileName ?? 'code',
@@ -508,9 +522,12 @@ export function useFileNavigation({
{sourceToRender}
@@ -522,7 +539,7 @@ export function useFileNavigation({
selectedVariant,
shouldHighlight,
preClassName,
- preRef,
+ setSource,
enhancedSource,
isEnhancing,
mainSlug,
@@ -531,6 +548,8 @@ export function useFileNavigation({
selectedVariantKey,
sourceEnhancers,
selectedFileNameInternal,
+ expanded,
+ expand,
]);
const selectedFileLines = React.useMemo(() => {
@@ -593,8 +612,11 @@ export function useFileNavigation({
selectedTransform,
)}
className={preClassName}
- ref={preRef}
+ fileName={f.originalName}
+ setSource={setSource}
shouldHighlight={shouldHighlight}
+ expanded={expanded}
+ expand={expand}
>
{f.source}
@@ -617,9 +639,12 @@ export function useFileNavigation({
selectedTransform,
)}
className={preClassName}
+ fileName={selectedVariant.fileName}
language={selectedVariant.language}
- ref={preRef}
+ setSource={setSource}
shouldHighlight={shouldHighlight}
+ expanded={expanded}
+ expand={expand}
>
{selectedVariant.source}
@@ -655,9 +680,12 @@ export function useFileNavigation({
selectedTransform,
)}
className={preClassName}
+ fileName={fileName}
language={language ?? getLanguageFromFileName(fileName)}
- ref={preRef}
+ setSource={setSource}
shouldHighlight={shouldHighlight}
+ expanded={expanded}
+ expand={expand}
>
{source}
@@ -675,7 +703,9 @@ export function useFileNavigation({
selectedVariantKey,
shouldHighlight,
preClassName,
- preRef,
+ setSource,
+ expanded,
+ expand,
]);
// Create a wrapper for selectFileName that handles transformed filenames and URL updates
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.test.ts b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
new file mode 100644
index 000000000..f8721a4cf
--- /dev/null
+++ b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
@@ -0,0 +1,1094 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import type { Position } from 'use-editable';
+import { useSourceEditing } from './useSourceEditing';
+import type { Code, ControlledCode, VariantCode, SourceComments } from '../CodeHighlighter/types';
+import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext';
+
+function createContext(
+ overrides: Partial = {},
+): CodeHighlighterContextType {
+ return {
+ code: {},
+ setCode: vi.fn(),
+ ...overrides,
+ };
+}
+
+function pos(line: number): Position {
+ return { position: 0, extent: 0, content: '', line };
+}
+
+function posWithExtent(line: number, extent: number): Position {
+ return { position: 0, extent, content: '', line };
+}
+
+/**
+ * Captures the ControlledCode produced by setSource by intercepting the
+ * setState updater function passed to context.setCode.
+ */
+function captureControlledCode(
+ context: CodeHighlighterContextType,
+ currentCode?: ControlledCode,
+): ControlledCode | undefined {
+ const setCode = context.setCode as ReturnType;
+ const updater = setCode.mock.lastCall?.[0];
+ if (typeof updater === 'function') {
+ return updater(currentCode);
+ }
+ return updater;
+}
+
+describe('useSourceEditing', () => {
+ describe('setSource availability', () => {
+ it('returns undefined when context has no setCode', () => {
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context: createContext({ setCode: undefined }),
+ selectedVariantKey: 'Default',
+ effectiveCode: {},
+ selectedVariant: { fileName: 'App.tsx', source: 'code' },
+ }),
+ );
+
+ expect(result.current.setSource).toBeUndefined();
+ });
+
+ it('returns undefined when disabled', () => {
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context: createContext(),
+ selectedVariantKey: 'Default',
+ effectiveCode: {},
+ selectedVariant: { fileName: 'App.tsx', source: 'code' },
+ disabled: true,
+ }),
+ );
+
+ expect(result.current.setSource).toBeUndefined();
+ });
+
+ it('returns undefined when selectedVariant is null (unloaded)', () => {
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context: createContext(),
+ selectedVariantKey: 'Default',
+ effectiveCode: { Default: 'https://example.com/demo' },
+ selectedVariant: null,
+ }),
+ );
+
+ expect(result.current.setSource).toBeUndefined();
+ });
+
+ it('returns setSource when context has setCode, variant is loaded, and not disabled', () => {
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context: createContext(),
+ selectedVariantKey: 'Default',
+ effectiveCode: {},
+ selectedVariant: { fileName: 'App.tsx', source: 'code' },
+ }),
+ );
+
+ expect(result.current.setSource).toBeTypeOf('function');
+ });
+ });
+
+ describe('editing main file', () => {
+ it('updates the main file source', () => {
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'original',
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!('edited'));
+
+ const controlled = captureControlledCode(context);
+ expect(controlled!.Default!.source).toBe('edited');
+ });
+
+ it('preserves extra files when editing main file', () => {
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'original',
+ extraFiles: {
+ 'styles.css': 'body { color: red; }',
+ 'helpers.ts': { source: 'export const h = 1;' },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!('edited'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.source).toBe('edited');
+ expect(variant.extraFiles).toBeDefined();
+ // String entries normalized to { source } objects
+ expect(variant.extraFiles!['styles.css']).toEqual({
+ source: 'body { color: red; }',
+ totalLines: 1,
+ });
+ expect(variant.extraFiles!['helpers.ts']).toEqual({
+ source: 'export const h = 1;',
+ totalLines: 1,
+ });
+ });
+ });
+
+ describe('editing extra file', () => {
+ it('updates the specified extra file', () => {
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'main code',
+ extraFiles: {
+ 'styles.css': 'old styles',
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!('new styles', 'styles.css'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.source).toBe('main code');
+ expect(variant.extraFiles!['styles.css']).toEqual({ source: 'new styles', totalLines: 1 });
+ });
+
+ it('preserves other extra files when editing one', () => {
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'main code',
+ extraFiles: {
+ 'styles.css': 'css content',
+ 'helpers.ts': { source: 'helper content' },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!('new css', 'styles.css'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.extraFiles!['styles.css']).toEqual({ source: 'new css', totalLines: 1 });
+ expect(variant.extraFiles!['helpers.ts']).toEqual({
+ source: 'helper content',
+ totalLines: 1,
+ });
+ });
+ });
+
+ describe('HAST source normalization', () => {
+ it('converts HAST root sources to plain text on first edit', () => {
+ const hastRoot = {
+ type: 'root' as const,
+ children: [{ type: 'text' as const, value: 'highlighted code' }],
+ };
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: hastRoot,
+ extraFiles: {
+ 'styles.css': { source: hastRoot },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!('edited', 'styles.css'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // Main source converted from HAST to string
+ expect(variant.source).toBe('highlighted code');
+ // Edited extra file has new value
+ expect(variant.extraFiles!['styles.css']).toEqual({ source: 'edited', totalLines: 1 });
+ });
+ });
+
+ describe('successive edits', () => {
+ it('preserves previous edits when editing again', () => {
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'original',
+ extraFiles: {
+ 'styles.css': 'original css',
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // First edit: main file
+ act(() => result.current.setSource!('first edit'));
+ const afterFirst = captureControlledCode(context);
+
+ // Second edit: extra file, passing previous controlled state
+ act(() => result.current.setSource!('new css', 'styles.css'));
+ const afterSecond = captureControlledCode(context, afterFirst);
+
+ expect(afterSecond!.Default!.source).toBe('first edit');
+ expect(afterSecond!.Default!.extraFiles!['styles.css']).toEqual({
+ source: 'new css',
+ totalLines: 1,
+ });
+ });
+ });
+
+ describe('comment shifting', () => {
+ it('shifts comments down when lines are added, and reverses on undo', () => {
+ const comments: SourceComments = { 1: ['@highlight'], 4: ['@focus'] };
+ const originalSource = 'line0\nline1\nline2\nline3';
+ const editedSource = 'line0\nline1\n\nline2\nline3';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Add a line after line 1 (0-indexed). Cursor ends on the new empty line (0-indexed line 2).
+ act(() => result.current.setSource!(editedSource, undefined, pos(2)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.comments![1]).toEqual(['@highlight']);
+ expect(variant.comments![5]).toEqual(['@focus']);
+ expect(variant.comments![4]).toBeUndefined();
+
+ // Undo: remove the added line. Cursor at 0-indexed line 1, delta = -1.
+ act(() => result.current.setSource!(originalSource, undefined, pos(1)));
+ const undone = captureControlledCode(context, controlled);
+
+ expect(undone!.Default!.comments).toEqual(comments);
+ });
+
+ it('shifts comments up when lines are removed, and reverses on undo', () => {
+ const comments: SourceComments = { 1: ['@highlight'], 5: ['@focus'] };
+ const originalSource = 'line0\nline1\nline2\nline3\nline4';
+ const editedSource = 'line0\nline1line2\nline3\nline4';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Delete line2 (merge into line1). Cursor at 0-indexed line 1, delta = -1.
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.comments![1]).toEqual(['@highlight']);
+ expect(variant.comments![4]).toEqual(['@focus']);
+ expect(variant.comments![5]).toBeUndefined();
+
+ // Undo: re-add the line. Cursor at 0-indexed line 2 (new line), delta = +1.
+ act(() => result.current.setSource!(originalSource, undefined, pos(2)));
+ const undone = captureControlledCode(context, controlled);
+
+ expect(undone!.Default!.comments).toEqual(comments);
+ });
+
+ it('collapses comments from deleted range and restores on undo', () => {
+ const comments: SourceComments = {
+ 1: ['@keep-before'],
+ 3: ['@deleted-1'],
+ 4: ['@deleted-2'],
+ 6: ['@keep-after'],
+ };
+ const originalSource = 'a\nb\nc\nd\ne\nf';
+ const editedSource = 'a\nb\ne\nf';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Delete lines c and d (0-indexed 2,3). Cursor at 0-indexed line 1, delta = -2.
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // Line 1 is before the edit — unchanged
+ expect(variant.comments![1]).toEqual(['@keep-before']);
+ // Lines 3 and 4 collapsed onto the edit line (line 2)
+ expect(variant.comments![2]).toEqual(['@deleted-1', '@deleted-2']);
+ // Line 6 shifted up by 2 to line 4
+ expect(variant.comments![4]).toEqual(['@keep-after']);
+ expect(variant.comments![6]).toBeUndefined();
+
+ // Undo: re-add the 2 deleted lines. Cursor at 0-indexed line 3, delta = +2.
+ act(() => result.current.setSource!(originalSource, undefined, pos(3)));
+ const undone = captureControlledCode(context, controlled);
+
+ // Comments fully restored to original positions
+ expect(undone!.Default!.comments).toEqual(comments);
+ // Collapse map cleared after full restore
+ expect(undone!.Default!.collapseMap).toBeUndefined();
+ });
+
+ it('keeps highlight in place when undoing a multi-line selection delete', () => {
+ // Simulates: user has highlight on lines 7-8, types text inside the
+ // highlighted region (adding 2 lines AFTER line 8), selects the typed
+ // text (extent > 0), backspaces to delete it, then presses Ctrl+Z.
+ //
+ // The undo replays the saved pre-deletion state, where `position`
+ // points to the SELECTION-START (not the post-edit cursor). Without
+ // accounting for `extent > 0`, shiftComments mistakenly thinks the
+ // edit happened earlier in the file and shifts the highlighted lines
+ // downward — so the user sees the highlight on the typed lines
+ // instead of on its original location.
+ const comments: SourceComments = {
+ 7: ['@highlight-start'],
+ 8: ['@highlight-end'],
+ };
+ const originalSource = 'L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11';
+ // Typed text adds 2 lines after L8: 'test', 'test', 'test'
+ // appended after L8's content.
+ const typedSource = 'L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8test\ntest\ntest\nL9\nL10\nL11';
+
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Step 1: type the text (single setSource for simplicity).
+ // Cursor lands at end of last "test" → 0-indexed line 9.
+ act(() => result.current.setSource!(typedSource, undefined, pos(9)));
+ const afterType = captureControlledCode(context);
+ // Highlight stays on lines 7 (L7) and 8 (L8test).
+ expect(afterType!.Default!.comments).toEqual(comments);
+
+ // Step 2: select the typed text and Backspace.
+ // Cursor lands at start of selection (end of L8) → 0-indexed line 7.
+ act(() => result.current.setSource!(originalSource, undefined, pos(7)));
+ const afterDelete = captureControlledCode(context, afterType);
+ expect(afterDelete!.Default!.comments).toEqual(comments);
+
+ // Step 3: Ctrl+Z restores the pre-Backspace state. The saved position
+ // is the SELECTION-START in the typed text — line 7 (0-indexed) with
+ // extent = 25 (length of selected text).
+ act(() => result.current.setSource!(typedSource, undefined, posWithExtent(7, 25)));
+ const afterUndo = captureControlledCode(context, afterDelete);
+
+ // Highlight must remain on lines 7 (L7) and 8 (L8test) — the same
+ // logical lines as before the delete. Without the extent-aware fix,
+ // they would incorrectly shift to lines 9 and 10 (the typed content).
+ expect(afterUndo!.Default!.comments).toEqual(comments);
+ });
+
+ it('reduces (does not shift) the highlight when deleting an empty line at the start', () => {
+ // Highlighted region: lines 7-9 where L7 is an empty/whitespace-only line.
+ // User backspaces at the start of L7, merging it into L6.
+ // Old: L6=' ', L7=' ' (@hl-start), L8=' ',
+ // L9=' ' (@hl-end)
+ // New: L6=' ', L7=' ', L8=' ' (@hl-end)
+ //
+ // Since the deleted L7 had no real content that shifted into L6, the
+ // user expects the highlight to "lose" that empty line and start on
+ // the next line (now L7 = ) — NOT shift the start marker
+ // up onto the line.
+ const comments: SourceComments = {
+ 7: ['@highlight-start'],
+ 9: ['@highlight-end'],
+ };
+ const originalSource =
+ "import * as React from 'react';\n\n\nfunction App() {\n return (\n \n \n \n \n \n );\n}";
+ const editedSource =
+ "import * as React from 'react';\n\n\nfunction App() {\n return (\n \n \n \n \n );\n}";
+
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Cursor lands at end of merged line 6 (0-indexed line 5). lineDelta = -1.
+ act(() => result.current.setSource!(editedSource, undefined, pos(5)));
+
+ const variant = captureControlledCode(context)!.Default!;
+
+ // @highlight-start should NOT collapse onto L6 (the line).
+ // Instead it should land on what is now L7 (the line),
+ // shrinking the highlighted range from 3 lines to 2.
+ expect(variant.comments![6]).toBeUndefined();
+ expect(variant.comments![7]).toEqual(['@highlight-start']);
+ // @highlight-end shifts from L9 to L8 (one line removed before it).
+ expect(variant.comments![8]).toEqual(['@highlight-end']);
+ });
+
+ it('places -end comments at editLine+1 instead of editLine when collapsing', () => {
+ // Simulates deleting whitespace before in JSX, merging two lines.
+ // @highlight-end should stay at the line AFTER the merged content, not
+ // collapse onto editLine where it would shrink the highlighted range.
+ const comments: SourceComments = {
+ 2: ['@highlight-start'],
+ 4: ['@highlight-end'],
+ };
+ const originalSource = 'a\nb\nc\nd\ne';
+ // Delete line d (merge c+d into "cd"). delta = -1, cursor 0-indexed line 2.
+ const editedSource = 'a\nb\ncd\ne';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!(editedSource, undefined, pos(2)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // @highlight-start unchanged (before edit)
+ expect(variant.comments![2]).toEqual(['@highlight-start']);
+ // @highlight-end placed at editLine+1 (line 4), NOT editLine (line 3)
+ // This preserves the range [2, 3] instead of shrinking to [2, 2]
+ expect(variant.comments![4]).toEqual(['@highlight-end']);
+ expect(variant.comments![3]).toBeUndefined();
+ // Boundary comments are not tracked in collapseMap
+ expect(variant.collapseMap).toBeUndefined();
+
+ // Re-add a line: @highlight-end shifts normally from 4 to 5,
+ // expanding the range to include the new line.
+ act(() => result.current.setSource!(originalSource, undefined, pos(3)));
+ const expanded = captureControlledCode(context, controlled);
+
+ expect(expanded!.Default!.comments![2]).toEqual(['@highlight-start']);
+ expect(expanded!.Default!.comments![5]).toEqual(['@highlight-end']);
+ });
+
+ it('separates regular and -end comments in the same deleted range', () => {
+ const comments: SourceComments = {
+ 2: ['@highlight-start'],
+ 4: ['@regular'],
+ 5: ['@highlight-end'],
+ 6: ['@after'],
+ };
+ const originalSource = 'a\nb\nc\nd\ne\nf';
+ // Delete lines c, d, e. delta = -3, cursor 0-indexed line 1.
+ const editedSource = 'a\nb\nf';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // @regular collapses onto editLine (2), next to @highlight-start
+ expect(variant.comments![2]).toEqual(['@highlight-start', '@regular']);
+ // @highlight-end at editLine+1 = 3, @after shifts from 6 to 3
+ expect(variant.comments![3]).toEqual(['@highlight-end', '@after']);
+ // Only @regular tracked in collapseMap, not @highlight-end
+ expect(variant.collapseMap![2]).toEqual([{ offset: 2, comments: ['@regular'] }]);
+
+ // Re-add 3 lines: @regular restores from collapseMap to line 4,
+ // but @highlight-end and @after shift normally from 3 to 6.
+ act(() => result.current.setSource!(originalSource, undefined, pos(4)));
+ const expanded = captureControlledCode(context, controlled);
+
+ expect(expanded!.Default!.comments![2]).toEqual(['@highlight-start']);
+ expect(expanded!.Default!.comments![4]).toEqual(['@regular']);
+ // @highlight-end and @after both shift to line 6
+ expect(expanded!.Default!.comments![6]).toEqual(['@highlight-end', '@after']);
+ });
+
+ it('does not shift comments when line count is unchanged', () => {
+ const comments: SourceComments = { 1: ['@highlight'], 3: ['@focus'] };
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'aaa\nbbb\nccc',
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Edit text on line 1 without changing line count
+ act(() => result.current.setSource!('aaa\nBBB\nccc', undefined, pos(1)));
+
+ const controlled = captureControlledCode(context);
+
+ expect(controlled!.Default!.comments).toEqual(comments);
+
+ // Undo: revert to original text, still same line count
+ act(() => result.current.setSource!('aaa\nbbb\nccc', undefined, pos(1)));
+ const undone = captureControlledCode(context, controlled);
+
+ expect(undone!.Default!.comments).toEqual(comments);
+ });
+
+ it('shifts extra file comments when editing, and reverses on undo', () => {
+ const extraComments: SourceComments = { 2: ['@highlight'], 5: ['@focus'] };
+ const originalExtra = 'a\nb\nc\nd\ne';
+ const editedExtra = 'a\nNEW\nb\nc\nd\ne';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'main',
+ comments: { 1: ['@main-highlight'] },
+ extraFiles: {
+ 'styles.css': { source: originalExtra, comments: extraComments },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Add a line in the extra file after line 0-indexed 0. Cursor at new line 1.
+ act(() => result.current.setSource!(editedExtra, 'styles.css', pos(1)));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // Main file comments untouched
+ expect(variant.comments).toEqual({ 1: ['@main-highlight'] });
+ // Extra file comments shifted down by 1
+ const extraEntry = variant.extraFiles!['styles.css'];
+ expect(extraEntry.comments![3]).toEqual(['@highlight']);
+ expect(extraEntry.comments![6]).toEqual(['@focus']);
+ expect(extraEntry.comments![2]).toBeUndefined();
+ expect(extraEntry.comments![5]).toBeUndefined();
+
+ // Undo: remove the added line. Cursor at 0-indexed line 0, delta = -1.
+ act(() => result.current.setSource!(originalExtra, 'styles.css', pos(0)));
+ const undone = captureControlledCode(context, controlled);
+
+ expect(undone!.Default!.comments).toEqual({ 1: ['@main-highlight'] });
+ expect(undone!.Default!.extraFiles!['styles.css'].comments).toEqual(extraComments);
+ });
+
+ it('partially restores collapsed comments when fewer lines are re-added', () => {
+ const comments: SourceComments = {
+ 3: ['@c'],
+ 4: ['@d'],
+ 5: ['@e'],
+ };
+ const originalSource = 'a\nb\nc\nd\ne\nf';
+ // Delete lines c, d, e → 3 lines removed
+ const editedSource = 'a\nb\nf';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Delete 3 lines (c, d, e). Cursor at 0-indexed line 1, delta = -3.
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const collapsed = captureControlledCode(context);
+ const v1 = collapsed!.Default!;
+
+ // All three collapsed onto edit line 2
+ expect(v1.comments![2]).toEqual(['@c', '@d', '@e']);
+ expect(v1.collapseMap).toBeDefined();
+
+ // Partial undo: add back 1 line (only @c at offset 1 restores)
+ const partialSource = 'a\nb\nNEW\nf';
+ act(() => result.current.setSource!(partialSource, undefined, pos(2)));
+ const partial = captureControlledCode(context, collapsed);
+ const v2 = partial!.Default!;
+
+ // @c restored to line 3 (editLine 2 + offset 1)
+ expect(v2.comments![3]).toEqual(['@c']);
+ // @d and @e remain collapsed on edit line 2
+ expect(v2.comments![2]).toEqual(['@d', '@e']);
+ // CollapseMap still has remaining entries
+ expect(v2.collapseMap![2]).toEqual([
+ { offset: 2, comments: ['@d'] },
+ { offset: 3, comments: ['@e'] },
+ ]);
+ });
+
+ it('correctly handles duplicate comment strings across collapsed entries', () => {
+ // Two different lines with the same comment string
+ const comments: SourceComments = {
+ 3: ['@highlight'],
+ 4: ['@highlight'],
+ };
+ const originalSource = 'a\nb\nc\nd';
+ const editedSource = 'a\nb';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Delete lines c and d. Cursor at 0-indexed line 1, delta = -2.
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const collapsed = captureControlledCode(context);
+ const v1 = collapsed!.Default!;
+
+ // Both @highlight collapsed onto edit line 2
+ expect(v1.comments![2]).toEqual(['@highlight', '@highlight']);
+
+ // Undo: add both lines back. Cursor at 0-indexed line 3, delta = +2.
+ act(() => result.current.setSource!(originalSource, undefined, pos(3)));
+ const undone = captureControlledCode(context, collapsed);
+ const v2 = undone!.Default!;
+
+ // Both @highlight restored to their original lines
+ expect(v2.comments).toEqual(comments);
+ // Edit line 2 should have no leftover comments
+ expect(v2.comments![2]).toBeUndefined();
+ expect(v2.collapseMap).toBeUndefined();
+ });
+
+ it('preserves pre-existing edit-line comments through collapse and restore', () => {
+ const comments: SourceComments = {
+ 2: ['@existing'],
+ 3: ['@collapsed-1'],
+ 4: ['@collapsed-2'],
+ };
+ const originalSource = 'a\nb\nc\nd';
+ const editedSource = 'a\nb';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Delete lines c and d. Cursor at 0-indexed line 1, delta = -2.
+ act(() => result.current.setSource!(editedSource, undefined, pos(1)));
+
+ const collapsed = captureControlledCode(context);
+ const v1 = collapsed!.Default!;
+
+ // Edit line 2 keeps @existing and gains collapsed comments
+ expect(v1.comments![2]).toEqual(['@existing', '@collapsed-1', '@collapsed-2']);
+
+ // Undo: add both lines back. Cursor at 0-indexed line 3, delta = +2.
+ act(() => result.current.setSource!(originalSource, undefined, pos(3)));
+ const undone = captureControlledCode(context, collapsed);
+
+ // Fully restored: @existing stays on line 2, collapsed comments back to original lines
+ expect(undone!.Default!.comments).toEqual(comments);
+ expect(undone!.Default!.collapseMap).toBeUndefined();
+ });
+
+ it('restores original comments after multiple edits at different positions are undone', () => {
+ const comments: SourceComments = {
+ 1: ['@a'],
+ 3: ['@c'],
+ 5: ['@e'],
+ };
+ const originalSource = 'a\nb\nc\nd\ne\nf';
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Edit 1: delete line c. Source: a\nb\nd\ne\nf (5 lines). Cursor at 0-indexed line 1.
+ const afterDel1 = 'a\nb\nd\ne\nf';
+ act(() => result.current.setSource!(afterDel1, undefined, pos(1)));
+ const state1 = captureControlledCode(context);
+
+ expect(state1!.Default!.comments![1]).toEqual(['@a']);
+ expect(state1!.Default!.comments![2]).toEqual(['@c']); // collapsed onto editLine 2
+ expect(state1!.Default!.comments![4]).toEqual(['@e']); // shifted from 5 to 4
+
+ // Edit 2: delete line e (now at 0-indexed line 3). Source: a\nb\nd\nf (4 lines). Cursor at 0-indexed line 2.
+ const afterDel2 = 'a\nb\nd\nf';
+ act(() => result.current.setSource!(afterDel2, undefined, pos(2)));
+ const state2 = captureControlledCode(context, state1);
+
+ expect(state2!.Default!.comments![1]).toEqual(['@a']);
+ expect(state2!.Default!.comments![2]).toEqual(['@c']);
+ expect(state2!.Default!.comments![3]).toEqual(['@e']); // collapsed onto editLine 3
+
+ // Undo edit 2: add line e back. Source: a\nb\nd\ne\nf (5 lines). Cursor at 0-indexed line 3.
+ act(() => result.current.setSource!(afterDel1, undefined, pos(3)));
+ const state3 = captureControlledCode(context, state2);
+
+ expect(state3!.Default!.comments![1]).toEqual(['@a']);
+ expect(state3!.Default!.comments![2]).toEqual(['@c']);
+ expect(state3!.Default!.comments![4]).toEqual(['@e']); // restored to line 4
+
+ // Undo edit 1: add line c back. Source: original (6 lines). Cursor at 0-indexed line 2.
+ act(() => result.current.setSource!(originalSource, undefined, pos(2)));
+ const state4 = captureControlledCode(context, state3);
+
+ // Fully restored to original comments
+ expect(state4!.Default!.comments).toEqual(comments);
+ expect(state4!.Default!.collapseMap).toBeUndefined();
+ });
+
+ it('preserves comments through toControlledCode normalization', () => {
+ const comments: SourceComments = { 1: ['@highlight'], 3: ['@focus'] };
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'line1\nline2\nline3',
+ comments,
+ extraFiles: {
+ 'helper.ts': { source: 'code', comments: { 2: ['@extra'] } },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // First edit triggers toControlledCode normalization (no position = comments cleared)
+ act(() => result.current.setSource!('edited'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ // Main comments cleared (no position data, can't track shifts)
+ expect(variant.comments).toBeUndefined();
+ // Extra file comments preserved (not edited)
+ expect(variant.extraFiles!['helper.ts'].comments).toEqual({ 2: ['@extra'] });
+ });
+
+ it('clears comments when setSource is called without position', () => {
+ const comments: SourceComments = { 1: ['@highlight'], 3: ['@focus'] };
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: 'line1\nline2\nline3',
+ comments,
+ extraFiles: {
+ 'helper.ts': { source: 'code', comments: { 2: ['@extra'] } },
+ },
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Edit main file without position
+ act(() => result.current.setSource!('new main'));
+
+ const controlled = captureControlledCode(context);
+ const variant = controlled!.Default!;
+
+ expect(variant.comments).toBeUndefined();
+ expect(variant.collapseMap).toBeUndefined();
+ // Extra file untouched
+ expect(variant.extraFiles!['helper.ts'].comments).toEqual({ 2: ['@extra'] });
+
+ // Edit extra file without position
+ act(() => result.current.setSource!('new helper', 'helper.ts'));
+ const afterExtra = captureControlledCode(context, controlled);
+
+ expect(afterExtra!.Default!.extraFiles!['helper.ts'].comments).toBeUndefined();
+ });
+ });
+
+ describe('preParsed cache write', () => {
+ function makeHast() {
+ return {
+ type: 'root' as const,
+ children: [{ type: 'text' as const, value: 'parsed' }],
+ };
+ }
+
+ it('writes preParsed HAST into context.preParsedCache keyed by the resolved file name', () => {
+ const preParsedCache = new Map();
+ const context = createContext({ preParsedCache });
+ const hast = makeHast();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode: { Default: { fileName: 'App.tsx', source: 'old' } },
+ selectedVariant: { fileName: 'App.tsx', source: 'old' },
+ }),
+ );
+
+ act(() => result.current.setSource!('new source', undefined, pos(0), hast));
+
+ expect(preParsedCache.get('App.tsx')).toEqual({ source: 'new source', hast });
+ });
+
+ it('uses the explicit fileName argument when provided', () => {
+ const preParsedCache = new Map();
+ const context = createContext({ preParsedCache });
+ const hast = makeHast();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode: {
+ Default: {
+ fileName: 'App.tsx',
+ source: 'main',
+ extraFiles: { 'helper.ts': { source: 'h' } },
+ },
+ },
+ selectedVariant: {
+ fileName: 'App.tsx',
+ source: 'main',
+ extraFiles: { 'helper.ts': { source: 'h' } },
+ },
+ }),
+ );
+
+ act(() => result.current.setSource!('new helper', 'helper.ts', pos(0), hast));
+
+ expect(preParsedCache.get('helper.ts')).toEqual({ source: 'new helper', hast });
+ expect(preParsedCache.has('App.tsx')).toBe(false);
+ });
+
+ it('does not write when preParsed is omitted', () => {
+ const preParsedCache = new Map();
+ const context = createContext({ preParsedCache });
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode: { Default: { fileName: 'App.tsx', source: 'old' } },
+ selectedVariant: { fileName: 'App.tsx', source: 'old' },
+ }),
+ );
+
+ act(() => result.current.setSource!('new source', undefined, pos(0)));
+
+ expect(preParsedCache.size).toBe(0);
+ });
+
+ it('does nothing when context has no preParsedCache', () => {
+ const context = createContext();
+ const hast = makeHast();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode: { Default: { fileName: 'App.tsx', source: 'old' } },
+ selectedVariant: { fileName: 'App.tsx', source: 'old' },
+ }),
+ );
+
+ // Should not throw.
+ expect(() =>
+ act(() => result.current.setSource!('new', undefined, pos(0), hast)),
+ ).not.toThrow();
+ });
+ });
+});
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.ts b/packages/docs-infra/src/useCode/useSourceEditing.ts
index 9908e8761..ad029768a 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.ts
@@ -1,56 +1,413 @@
import * as React from 'react';
-import type { Code, ControlledCode, VariantCode } from '../CodeHighlighter/types';
+import type { Root as HastRoot } from 'hast';
+import type { Position } from './useEditable';
+import type {
+ Code,
+ CollapseMap,
+ ControlledCode,
+ ControlledVariantExtraFiles,
+ SourceComments,
+ VariantCode,
+} from '../CodeHighlighter/types';
import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext';
+import { stringOrHastToString } from '../pipeline/hastUtils';
+
+export type { Position };
+
+/**
+ * Internal `setSource` shape used by the editing pipeline. The 3rd and 4th
+ * arguments (caret position, pre-parsed HAST) are wired between sibling
+ * hooks (`useEditable` → `Pre` → `useSourceEditing`) and are NOT part of
+ * the public `useCode` contract — host code should treat `setSource` as
+ * `(source, fileName?) => void`.
+ */
+export type SetSource = (
+ source: string,
+ fileName?: string,
+ position?: Position,
+ preParsed?: HastRoot,
+) => void;
interface UseSourceEditingProps {
context?: CodeHighlighterContextType;
selectedVariantKey: string;
effectiveCode: Code;
selectedVariant: VariantCode | null;
+ disabled?: boolean;
}
export interface UseSourceEditingResult {
- setSource?: (source: string) => void;
+ setSource?: SetSource;
+}
+
+interface ShiftResult {
+ comments: SourceComments | undefined;
+ collapseMap: CollapseMap | undefined;
+}
+
+/**
+ * Counts the number of lines in a string and records which 1-indexed lines are
+ * empty/whitespace-only, in a single pass, without allocating a line array.
+ * `emptyLines` is omitted when no blank lines were found to keep the common
+ * case allocation-free.
+ */
+function analyzeSource(source: string): { totalLines: number; emptyLines?: number[] } {
+ let totalLines = 1;
+ let emptyLines: number[] | undefined;
+ let lineStart = 0;
+ const len = source.length;
+ for (let i = 0; i <= len; i += 1) {
+ if (i === len || source.charCodeAt(i) === 0x0a /* \n */) {
+ let isEmpty = true;
+ for (let j = lineStart; j < i; j += 1) {
+ const ch = source.charCodeAt(j);
+ // 0x20=space, 0x09=tab, 0x0D=CR, 0x0B=VT, 0x0C=FF
+ if (ch !== 0x20 && ch !== 0x09 && ch !== 0x0d && ch !== 0x0b && ch !== 0x0c) {
+ isEmpty = false;
+ break;
+ }
+ }
+ if (isEmpty) {
+ if (!emptyLines) {
+ emptyLines = [];
+ }
+ emptyLines.push(totalLines);
+ }
+ if (i < len) {
+ totalLines += 1;
+ lineStart = i + 1;
+ }
+ }
+ }
+ return emptyLines ? { totalLines, emptyLines } : { totalLines };
}
/**
- * Hook for managing source code editing functionality
+ * Shifts 1-indexed comment line numbers after a source edit.
+ * Accepts a precomputed `lineDelta` (positive = lines added, negative = lines deleted)
+ * and the cursor `position` (0-indexed in the new text) to determine which
+ * comments move and by how much.
+ *
+ * When lines are deleted, comments from the deleted range are collapsed
+ * onto the edit line and recorded in a collapseMap so they can be restored
+ * if the deletion is undone (lines re-added at the same position).
+ *
+ * Empty/whitespace-only deleted lines are special: since they had no real
+ * content that "shifted upward" into editLine, their comments are pushed
+ * to editLine + 1 (like `-end` boundary markers) so the highlighted region
+ * shrinks instead of shifting onto the previous line.
+ */
+function shiftComments(
+ comments: SourceComments | undefined,
+ lineDelta: number,
+ position: Position,
+ existingCollapseMap: CollapseMap | undefined,
+ oldEmptyLines?: number[],
+): ShiftResult {
+ if (!comments || Object.keys(comments).length === 0) {
+ return { comments, collapseMap: existingCollapseMap };
+ }
+
+ if (lineDelta === 0) {
+ return { comments, collapseMap: existingCollapseMap };
+ }
+
+ // position.line is 0-indexed in the new text.
+ // lineDelta is positive for insertions and negative for deletions.
+ // Convert to the 1-indexed line in old text that the cursor was on:
+ // For additions (lineDelta > 0):
+ // - Forward typing: position is the POST-edit cursor (extent === 0).
+ // Cursor moved down by lineDelta, so old line = position.line - lineDelta.
+ // - Undo of a multi-line delete: the saved position has extent > 0 and
+ // points to the SELECTION-START in the redone text — i.e. where the
+ // re-inserted lines begin. The "edit line" is that line itself; the
+ // new lines come AFTER it.
+ // For deletions (lineDelta < 0): cursor stayed where it was, old line = position.line.
+ const isUndoOfMultiLineDelete = lineDelta > 0 && position.extent > 0;
+ const editLine = isUndoOfMultiLineDelete
+ ? position.line + 1
+ : position.line - Math.max(0, lineDelta) + 1; // 1-indexed
+
+ const shifted: SourceComments = {};
+ let collapseMap: CollapseMap = existingCollapseMap ? { ...existingCollapseMap } : {};
+ const newCollapsed: Array<{ offset: number; comments: string[] }> = [];
+
+ // Build a list of comment strings to exclude from the edit line after restore.
+ // Uses an array (not Set) to correctly handle duplicate comment strings
+ // across separate collapsed entries.
+ let restoredComments: string[] | undefined;
+
+ // On expansion, check if we can restore previously collapsed comments
+ if (lineDelta > 0 && collapseMap[editLine]) {
+ const entries = collapseMap[editLine];
+ const restored: Array<{ offset: number; comments: string[] }> = [];
+ const remaining: Array<{ offset: number; comments: string[] }> = [];
+
+ for (const entry of entries) {
+ if (entry.offset <= lineDelta) {
+ restored.push(entry);
+ } else {
+ remaining.push(entry);
+ }
+ }
+
+ // Place restored comments at their original offsets from the edit line
+ restoredComments = [];
+ for (const entry of restored) {
+ const restoredLine = editLine + entry.offset;
+ shifted[restoredLine] = [...(shifted[restoredLine] ?? []), ...entry.comments];
+ restoredComments.push(...entry.comments);
+ }
+
+ if (remaining.length > 0) {
+ collapseMap[editLine] = remaining;
+ } else {
+ delete collapseMap[editLine];
+ }
+ }
+
+ // O(1) lookup against the precomputed empty-line set from the old source.
+ const oldEmptyLineSet =
+ oldEmptyLines && oldEmptyLines.length > 0 ? new Set(oldEmptyLines) : undefined;
+
+ for (const [lineStr, commentArr] of Object.entries(comments)) {
+ const line = Number(lineStr);
+ if (line <= editLine) {
+ // Before or at the edit line — unchanged.
+ // If this is the edit line and we restored comments from it, filter them out.
+ let arr = commentArr;
+ if (line === editLine && restoredComments) {
+ const remaining = [...commentArr];
+ for (const c of restoredComments) {
+ const idx = remaining.indexOf(c);
+ if (idx !== -1) {
+ remaining.splice(idx, 1);
+ }
+ }
+ arr = remaining;
+ }
+ if (arr.length > 0) {
+ shifted[line] = [...(shifted[line] ?? []), ...arr];
+ }
+ } else if (lineDelta < 0 && line <= editLine - lineDelta) {
+ // Within the deleted range — collapse comments onto the edit line.
+ // Boundary comments (ending with '-end') go to editLine + 1 instead,
+ // so range-end markers stay at the first line after the highlighted range.
+ // Boundary comments are NOT tracked in collapseMap — they shift normally
+ // on subsequent edits so the range naturally expands/contracts.
+ //
+ // Empty/whitespace-only deleted lines also push their regular comments
+ // to editLine + 1: nothing actually shifted upward into editLine, so the
+ // highlighted region should shrink rather than expand onto the line above.
+ const wasEmptyLine = oldEmptyLineSet?.has(line) ?? false;
+ const regular = commentArr.filter((c) => !c.endsWith('-end'));
+ const boundary = commentArr.filter((c) => c.endsWith('-end'));
+
+ if (regular.length > 0) {
+ if (wasEmptyLine) {
+ const target = editLine + 1;
+ shifted[target] = [...(shifted[target] ?? []), ...regular];
+ } else {
+ shifted[editLine] = [...(shifted[editLine] ?? []), ...regular];
+ newCollapsed.push({ offset: line - editLine, comments: regular });
+ }
+ }
+ if (boundary.length > 0) {
+ const boundaryTarget = editLine + 1;
+ shifted[boundaryTarget] = [...(shifted[boundaryTarget] ?? []), ...boundary];
+ }
+ } else {
+ // After the edit — shift
+ const newLine = line + lineDelta;
+ shifted[newLine] = [...(shifted[newLine] ?? []), ...commentArr];
+ }
+ }
+
+ // Also shift existing collapse map entries that are after the edit line
+ const shiftedCollapseMap: CollapseMap = {};
+ for (const [lineStr, entries] of Object.entries(collapseMap)) {
+ const line = Number(lineStr);
+ if (line <= editLine) {
+ shiftedCollapseMap[line] = entries;
+ } else {
+ shiftedCollapseMap[line + lineDelta] = entries;
+ }
+ }
+ collapseMap = shiftedCollapseMap;
+
+ if (newCollapsed.length > 0) {
+ collapseMap[editLine] = [...(collapseMap[editLine] ?? []), ...newCollapsed];
+ }
+
+ const finalCollapseMap = Object.keys(collapseMap).length > 0 ? collapseMap : undefined;
+
+ return { comments: shifted, collapseMap: finalCollapseMap };
+}
+
+/**
+ * Converts Code to ControlledCode, normalizing sources and extraFiles entries.
+ * VariantSource can be HAST nodes; ControlledCode requires plain strings.
+ * VariantExtraFiles allows plain string entries; ControlledVariantExtraFiles
+ * requires `{ source }` objects. Without this normalization, parseControlledCode
+ * reads `.source` on a string and gets `undefined`, dropping file content.
+ */
+function toControlledCode(code: Code): ControlledCode {
+ const result: ControlledCode = {};
+ for (const [key, variant] of Object.entries(code)) {
+ if (!variant || typeof variant === 'string') {
+ continue;
+ }
+ const source = variant.source != null ? stringOrHastToString(variant.source) : variant.source;
+
+ let extraFiles: ControlledVariantExtraFiles | undefined;
+ if (variant.extraFiles) {
+ extraFiles = {};
+ for (const [fileName, entry] of Object.entries(variant.extraFiles)) {
+ if (typeof entry === 'string') {
+ extraFiles[fileName] = { source: entry, ...analyzeSource(entry) };
+ } else {
+ const extraSource = entry.source != null ? stringOrHastToString(entry.source) : null;
+ extraFiles[fileName] = {
+ source: extraSource,
+ ...(entry.comments ? { comments: entry.comments } : {}),
+ ...(extraSource != null ? analyzeSource(extraSource) : {}),
+ };
+ }
+ }
+ }
+
+ result[key] = {
+ ...variant,
+ source,
+ ...(source != null ? analyzeSource(source) : {}),
+ ...(extraFiles ? { extraFiles } : {}),
+ } as ControlledCode[string];
+ }
+ return result;
+}
+
+/**
+ * Hook for managing source code editing functionality.
+ *
+ * Returns a `setSource(source, fileName?)` callback that updates the correct file
+ * (main or extra) within the controlled code for the current variant.
+ * If `fileName` is omitted, the currently selected file is assumed.
*/
export function useSourceEditing({
context,
selectedVariantKey,
effectiveCode,
selectedVariant,
+ disabled,
}: UseSourceEditingProps): UseSourceEditingResult {
const contextSetCode = context?.setCode;
- const setSource = React.useCallback(
- (source: string) => {
- if (contextSetCode) {
- contextSetCode((currentCode: ControlledCode | undefined) => {
- let newCode: ControlledCode = {};
- if (!currentCode) {
- newCode = { ...effectiveCode } as ControlledCode; // TODO: ensure all source are strings
- }
-
- newCode[selectedVariantKey] = {
- ...(newCode[selectedVariantKey] || selectedVariant),
- source,
- extraFiles: {},
- } as ControlledCode[string];
-
- return newCode;
- });
- } else {
+ const setSource = React.useCallback(
+ (source, fileName, position, preParsed) => {
+ if (!contextSetCode) {
console.warn(
'setCode is not available in the current context. Ensure you are using CodeControllerContext.',
);
+ return;
+ }
+
+ // Stash any pre-computed parse result against the resolved file name
+ // BEFORE the controlled-code update commits, so that the synchronous
+ // `parseControlledCode` pass triggered by the resulting React render
+ // can reuse the cached HAST instead of re-parsing the new source.
+ // The cache is owned by `CodeHighlighterClient` (per-highlighter
+ // state) and exposed on the context for both the writer (here) and
+ // reader (`parseControlledCode`).
+ const resolvedFileName = fileName ?? selectedVariant?.fileName;
+ const preParsedCache = context?.preParsedCache;
+ if (preParsed !== undefined && preParsedCache && resolvedFileName) {
+ preParsedCache.set(resolvedFileName, {
+ source,
+ hast: preParsed,
+ });
}
+
+ contextSetCode((currentCode: ControlledCode | undefined) => {
+ const newCode: ControlledCode = currentCode
+ ? { ...currentCode }
+ : toControlledCode(effectiveCode);
+
+ const variant = newCode[selectedVariantKey];
+ if (!variant) {
+ return newCode;
+ }
+
+ const effectiveFileName = fileName ?? selectedVariant?.fileName;
+ const isMainFile = effectiveFileName === selectedVariant?.fileName;
+
+ if (isMainFile) {
+ if (source === variant.source) {
+ return currentCode ?? newCode;
+ }
+ const { totalLines: newLineCount, emptyLines: newEmptyLines } = analyzeSource(source);
+ const oldLineCount =
+ variant.totalLines ??
+ (variant.source != null ? analyzeSource(variant.source).totalLines : 0);
+ const { comments: shiftedComments, collapseMap: newCollapseMap } = position
+ ? shiftComments(
+ variant.comments,
+ newLineCount - oldLineCount,
+ position,
+ variant.collapseMap,
+ variant.emptyLines,
+ )
+ : { comments: undefined, collapseMap: undefined };
+ newCode[selectedVariantKey] = {
+ ...variant,
+ source,
+ totalLines: newLineCount,
+ emptyLines: newEmptyLines,
+ comments: shiftedComments,
+ collapseMap: newCollapseMap,
+ };
+ } else if (effectiveFileName) {
+ const extraEntry = variant.extraFiles?.[effectiveFileName];
+ if (source === extraEntry?.source) {
+ return currentCode ?? newCode;
+ }
+ const { totalLines: newLineCount, emptyLines: newEmptyLines } = analyzeSource(source);
+ const oldLineCount =
+ extraEntry?.totalLines ??
+ (extraEntry?.source != null ? analyzeSource(extraEntry.source).totalLines : 0);
+ const { comments: shiftedComments, collapseMap: newCollapseMap } = position
+ ? shiftComments(
+ extraEntry?.comments,
+ newLineCount - oldLineCount,
+ position,
+ extraEntry?.collapseMap,
+ extraEntry?.emptyLines,
+ )
+ : { comments: undefined, collapseMap: undefined };
+ newCode[selectedVariantKey] = {
+ ...variant,
+ extraFiles: {
+ ...variant.extraFiles,
+ [effectiveFileName]: {
+ ...extraEntry,
+ source,
+ totalLines: newLineCount,
+ emptyLines: newEmptyLines,
+ comments: shiftedComments,
+ collapseMap: newCollapseMap,
+ },
+ },
+ };
+ }
+
+ return newCode;
+ });
},
- [contextSetCode, selectedVariantKey, effectiveCode, selectedVariant],
+ [contextSetCode, selectedVariantKey, effectiveCode, selectedVariant, context?.preParsedCache],
);
+ const isEditable = !disabled && Boolean(contextSetCode) && Boolean(selectedVariant);
+
return {
- setSource: contextSetCode ? setSource : undefined,
+ setSource: isEditable ? setSource : undefined,
};
}
diff --git a/packages/docs-infra/src/useCode/useSourceEnhancing.test.ts b/packages/docs-infra/src/useCode/useSourceEnhancing.test.ts
new file mode 100644
index 000000000..37293798e
--- /dev/null
+++ b/packages/docs-infra/src/useCode/useSourceEnhancing.test.ts
@@ -0,0 +1,567 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import type { Root as HastRoot } from 'hast';
+import type { SourceEnhancer, SourceComments } from '../CodeHighlighter/types';
+import { useSourceEnhancing } from './useSourceEnhancing';
+
+function makeHast(value: string): HastRoot {
+ return {
+ type: 'root',
+ children: [{ type: 'text', value }],
+ };
+}
+
+function textOf(source: unknown): string | undefined {
+ if (
+ source &&
+ typeof source === 'object' &&
+ 'children' in source &&
+ Array.isArray((source as HastRoot).children)
+ ) {
+ const first = (source as HastRoot).children[0];
+ if (first && first.type === 'text') {
+ return first.value;
+ }
+ }
+ return undefined;
+}
+
+describe('useSourceEnhancing', () => {
+ describe('no enhancers', () => {
+ it('should return the source unchanged when no enhancers are provided', () => {
+ const source = makeHast('original');
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: undefined,
+ }),
+ );
+
+ expect(result.current.enhancedSource).toBe(source);
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should return the source unchanged when enhancers array is empty', () => {
+ const source = makeHast('original');
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [],
+ }),
+ );
+
+ expect(result.current.enhancedSource).toBe(source);
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should return null when source is null', () => {
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source: null,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [() => makeHast('should not run')],
+ }),
+ );
+
+ expect(result.current.enhancedSource).toBeNull();
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should return null when source is undefined', () => {
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source: undefined,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [() => makeHast('should not run')],
+ }),
+ );
+
+ expect(result.current.enhancedSource).toBeNull();
+ expect(result.current.isEnhancing).toBe(false);
+ });
+ });
+
+ describe('string sources', () => {
+ it('should return string sources unchanged since they cannot be enhanced', () => {
+ const source = 'const x = 1;';
+
+ const enhancer = vi.fn(() => makeHast('enhanced'));
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(result.current.enhancedSource).toBe(source);
+ expect(result.current.isEnhancing).toBe(false);
+ expect(enhancer).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('sync enhancers', () => {
+ it('should apply a single sync enhancer immediately', () => {
+ const source = makeHast('original');
+ const enhanced = makeHast('enhanced');
+ const enhancer = vi.fn(() => enhanced);
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced');
+ expect(result.current.isEnhancing).toBe(false);
+ expect(enhancer).toHaveBeenCalledExactlyOnceWith(source, undefined, 'test.tsx');
+ });
+
+ it('should chain multiple sync enhancers in order', () => {
+ const source = makeHast('original');
+ const callOrder: string[] = [];
+
+ const first: SourceEnhancer = (root) => {
+ callOrder.push('first');
+ expect(textOf(root)).toBe('original');
+ return makeHast('first');
+ };
+ const second: SourceEnhancer = (root) => {
+ callOrder.push('second');
+ expect(textOf(root)).toBe('first');
+ return makeHast('second');
+ };
+ const third: SourceEnhancer = (root) => {
+ callOrder.push('third');
+ expect(textOf(root)).toBe('second');
+ return makeHast('third');
+ };
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [first, second, third],
+ }),
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('third');
+ expect(result.current.isEnhancing).toBe(false);
+ expect(callOrder).toEqual(['first', 'second', 'third']);
+ });
+
+ it('should pass comments and fileName to enhancers', () => {
+ const source = makeHast('original');
+ const comments: SourceComments = { 1: ['@highlight'] };
+ const enhancer = vi.fn((root: HastRoot) => root);
+
+ renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'MyComponent.tsx',
+ comments,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(enhancer).toHaveBeenCalledWith(source, comments, 'MyComponent.tsx');
+ });
+
+ it('should use "unknown" as fileName when fileName is undefined', () => {
+ const source = makeHast('original');
+ const enhancer = vi.fn((root: HastRoot) => root);
+
+ renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: undefined,
+ comments: undefined,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(enhancer).toHaveBeenCalledWith(source, undefined, 'unknown');
+ });
+
+ it('should re-run sync enhancers when source changes', () => {
+ const sourceA = makeHast('A');
+ const sourceB = makeHast('B');
+ const enhancer = vi.fn((root: HastRoot) => makeHast(`enhanced-${textOf(root)}`));
+ const enhancers: SourceEnhancer[] = [enhancer];
+
+ const { result, rerender } = renderHook(
+ ({ source }) =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { source: sourceA as typeof sourceA | typeof sourceB } },
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced-A');
+ expect(enhancer).toHaveBeenCalledTimes(1);
+
+ rerender({ source: sourceB });
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced-B');
+ expect(enhancer).toHaveBeenCalledTimes(2);
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should re-run sync enhancers when comments change', () => {
+ const source = makeHast('original');
+ const commentsA: SourceComments = { 1: ['@highlight'] };
+ const commentsB: SourceComments = { 2: ['@highlight'] };
+ const enhancer = vi.fn((root: HastRoot) => root);
+ const enhancers: SourceEnhancer[] = [enhancer];
+
+ const { rerender } = renderHook(
+ ({ comments }) =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { comments: commentsA as SourceComments } },
+ );
+
+ expect(enhancer).toHaveBeenCalledTimes(1);
+ expect(enhancer).toHaveBeenCalledWith(source, commentsA, 'test.tsx');
+
+ rerender({ comments: commentsB });
+
+ expect(enhancer).toHaveBeenCalledTimes(2);
+ expect(enhancer).toHaveBeenLastCalledWith(source, commentsB, 'test.tsx');
+ });
+
+ it('should re-run sync enhancers when fileName changes', () => {
+ const source = makeHast('original');
+ const enhancer = vi.fn((root: HastRoot) => root);
+ const enhancers: SourceEnhancer[] = [enhancer];
+
+ const { rerender } = renderHook(
+ ({ name }) =>
+ useSourceEnhancing({
+ source,
+ fileName: name,
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { name: 'file-a.tsx' } },
+ );
+
+ expect(enhancer).toHaveBeenCalledTimes(1);
+ expect(enhancer).toHaveBeenCalledWith(source, undefined, 'file-a.tsx');
+
+ rerender({ name: 'file-b.tsx' });
+
+ expect(enhancer).toHaveBeenCalledTimes(2);
+ expect(enhancer).toHaveBeenLastCalledWith(source, undefined, 'file-b.tsx');
+ });
+
+ it('should not re-run enhancers when inputs are unchanged', () => {
+ const source = makeHast('original');
+ const enhancers: SourceEnhancer[] = [vi.fn((root: HastRoot) => root)];
+
+ const { rerender } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ );
+
+ rerender();
+ rerender();
+
+ expect(enhancers[0]).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('async enhancers', () => {
+ it('should show original source immediately and resolve async enhancer', async () => {
+ const source = makeHast('original');
+ const enhanced = makeHast('enhanced-async');
+
+ const asyncEnhancer: SourceEnhancer = async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ return enhanced;
+ };
+ const enhancers: SourceEnhancer[] = [asyncEnhancer];
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ );
+
+ // Before async resolves, shows the original (no sync enhancers ran before it)
+ expect(textOf(result.current.enhancedSource)).toBe('original');
+ expect(result.current.isEnhancing).toBe(true);
+
+ await vi.waitFor(() => {
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced-async');
+ });
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should apply sync enhancers immediately and continue with async ones', async () => {
+ const source = makeHast('original');
+
+ const syncEnhancer: SourceEnhancer = () => makeHast('sync-done');
+
+ const asyncEnhancer: SourceEnhancer = async (root) => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ return makeHast(`async-after-${textOf(root)}`);
+ };
+ const enhancers: SourceEnhancer[] = [syncEnhancer, asyncEnhancer];
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ );
+
+ // Immediately shows sync-enhanced result
+ expect(textOf(result.current.enhancedSource)).toBe('sync-done');
+ expect(result.current.isEnhancing).toBe(true);
+
+ // Eventually resolves with async result
+ await vi.waitFor(() => {
+ expect(textOf(result.current.enhancedSource)).toBe('async-after-sync-done');
+ });
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should run sync enhancers after async ones without re-running the sync ones', async () => {
+ const source = makeHast('original');
+ const syncBefore = vi.fn(() => makeHast('sync-before'));
+ const syncAfter = vi.fn((root) => makeHast(`sync-after-${textOf(root)}`));
+
+ const asyncEnhancer: SourceEnhancer = async (root) => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ return makeHast(`async-${textOf(root)}`);
+ };
+
+ const enhancers: SourceEnhancer[] = [syncBefore, asyncEnhancer, syncAfter];
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ );
+
+ // syncBefore runs during render, asyncEnhancer is pending
+ expect(textOf(result.current.enhancedSource)).toBe('sync-before');
+ expect(syncBefore).toHaveBeenCalledTimes(1);
+ // syncAfter hasn't run yet — it's after the async one
+ expect(syncAfter).not.toHaveBeenCalled();
+
+ await vi.waitFor(() => {
+ expect(textOf(result.current.enhancedSource)).toBe('sync-after-async-sync-before');
+ });
+
+ // syncBefore was NOT re-run for the async path
+ expect(syncBefore).toHaveBeenCalledTimes(1);
+ // syncAfter ran once in the async continuation
+ expect(syncAfter).toHaveBeenCalledTimes(1);
+ });
+
+ it('should cancel pending async work when source changes', async () => {
+ const sourceA = makeHast('A');
+ const sourceB = makeHast('B');
+
+ let resolveA: () => void;
+ const asyncEnhancerA = vi.fn(
+ async () =>
+ new Promise((resolve) => {
+ resolveA = () => resolve(makeHast('async-A'));
+ }),
+ );
+
+ const syncEnhancerB: SourceEnhancer = () => makeHast('sync-B');
+
+ const { result, rerender } = renderHook(
+ ({ source, enhancers }) =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ {
+ initialProps: {
+ source: sourceA as HastRoot,
+ enhancers: [asyncEnhancerA] as SourceEnhancer[],
+ },
+ },
+ );
+
+ expect(result.current.isEnhancing).toBe(true);
+
+ // Switch to a new source with a sync enhancer
+ rerender({ source: sourceB, enhancers: [syncEnhancerB] });
+
+ expect(textOf(result.current.enhancedSource)).toBe('sync-B');
+ expect(result.current.isEnhancing).toBe(false);
+
+ // Resolve the old async enhancer — should be cancelled
+ await act(async () => {
+ resolveA!();
+ await new Promise((resolve) => {
+ setTimeout(resolve, 20);
+ });
+ });
+
+ // Should still show the new source, not the stale async result
+ expect(textOf(result.current.enhancedSource)).toBe('sync-B');
+ });
+ });
+
+ describe('hastJson and hastGzip sources', () => {
+ it('should resolve hastJson sources before enhancing', () => {
+ const hast = makeHast('from-json');
+ const source = { hastJson: JSON.stringify(hast) };
+ const enhancer = vi.fn((root: HastRoot) => makeHast(`enhanced-${textOf(root)}`));
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced-from-json');
+ expect(result.current.isEnhancing).toBe(false);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle enhancers that mutate the root in-place', () => {
+ const source = makeHast('original');
+ const enhancer: SourceEnhancer = (root) => {
+ (root.children[0] as { value: string }).value = 'mutated';
+ return root;
+ };
+
+ const { result } = renderHook(() =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: [enhancer],
+ }),
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('mutated');
+ });
+
+ it('should handle switching from enhancers to no enhancers', () => {
+ const source = makeHast('original');
+ const enhancer: SourceEnhancer = () => makeHast('enhanced');
+
+ const { result, rerender } = renderHook(
+ ({ enhancers }) =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { enhancers: [enhancer] as SourceEnhancer[] | undefined } },
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced');
+
+ rerender({ enhancers: undefined });
+
+ expect(result.current.enhancedSource).toBe(source);
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should handle switching from no enhancers to enhancers', () => {
+ const source = makeHast('original');
+ const enhancer: SourceEnhancer = () => makeHast('enhanced');
+
+ const { result, rerender } = renderHook(
+ ({ enhancers }) =>
+ useSourceEnhancing({
+ source,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { enhancers: undefined as SourceEnhancer[] | undefined } },
+ );
+
+ expect(result.current.enhancedSource).toBe(source);
+
+ rerender({ enhancers: [enhancer] });
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced');
+ expect(result.current.isEnhancing).toBe(false);
+ });
+
+ it('should handle source changing to null', () => {
+ const source = makeHast('original');
+ const enhancer: SourceEnhancer = () => makeHast('enhanced');
+ const enhancers: SourceEnhancer[] = [enhancer];
+
+ const { result, rerender } = renderHook(
+ ({ src }) =>
+ useSourceEnhancing({
+ source: src,
+ fileName: 'test.tsx',
+ comments: undefined,
+ sourceEnhancers: enhancers,
+ }),
+ { initialProps: { src: source as HastRoot | null } },
+ );
+
+ expect(textOf(result.current.enhancedSource)).toBe('enhanced');
+
+ rerender({ src: null });
+
+ expect(result.current.enhancedSource).toBeNull();
+ expect(result.current.isEnhancing).toBe(false);
+ });
+ });
+});
diff --git a/packages/docs-infra/src/useCode/useSourceEnhancing.ts b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
index c533eca1c..f09161fe2 100644
--- a/packages/docs-infra/src/useCode/useSourceEnhancing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
@@ -3,12 +3,7 @@
import * as React from 'react';
import type { Root as HastRoot } from 'hast';
import { decompressHast } from '../pipeline/hastUtils';
-import type {
- SourceEnhancers,
- SourceComments,
- VariantSource,
- HastRoot as TypedHastRoot,
-} from '../CodeHighlighter/types';
+import type { SourceEnhancers, SourceComments, VariantSource } from '../CodeHighlighter/types';
/**
* Type guard to check if a source value is a HAST root node.
@@ -53,28 +48,60 @@ function resolveHastRoot(source: VariantSource | undefined): HastRoot | null {
}
/**
- * Applies enhancers sequentially to a HAST root.
+ * Applies enhancers sequentially to a HAST root, starting from a given index.
* Each enhancer receives the output of the previous enhancer in the chain.
- *
- * @param source - The initial HAST root to enhance
- * @param comments - Comments extracted from the source code (keyed by line number)
- * @param fileName - The name of the file being enhanced (used for context)
- * @param enhancers - Array of enhancer functions to apply in order
- * @returns The enhanced HAST root after all enhancers have been applied
*/
-async function applyEnhancers(
+async function applyEnhancersFrom(
source: HastRoot,
comments: SourceComments | undefined,
fileName: string,
enhancers: SourceEnhancers,
+ startIndex: number,
): Promise {
- return enhancers.reduce(
- async (accPromise, enhancer) => {
- const acc = await accPromise;
- return enhancer(acc, comments, fileName);
- },
- Promise.resolve(source as TypedHastRoot),
- );
+ return enhancers
+ .slice(startIndex)
+ .reduce<
+ Promise
+ >((prev, enhancer) => prev.then((current) => enhancer(current, comments, fileName)), Promise.resolve(source));
+}
+
+interface SyncEnhanceResult {
+ /** The result after applying all sync enhancers (and resolving any leading async ones) */
+ syncResult: HastRoot;
+ /** Index of the first enhancer that returned a Promise, or enhancers.length if all were sync */
+ asyncStartIndex: number;
+ /** The promise returned by the first async enhancer, if any */
+ firstAsyncPromise: Promise | null;
+}
+
+/**
+ * Runs enhancers in order until one returns a Promise.
+ * Returns the sync-enhanced result up to that point, plus the pending promise
+ * and its index so the caller can continue from there without re-running sync work.
+ */
+function applyEnhancersUntilAsync(
+ source: HastRoot,
+ comments: SourceComments | undefined,
+ fileName: string,
+ enhancers: SourceEnhancers,
+): SyncEnhanceResult {
+ let current: HastRoot = source;
+ for (let i = 0; i < enhancers.length; i += 1) {
+ const result = enhancers[i](current, comments, fileName);
+ if (result instanceof Promise) {
+ return {
+ syncResult: current,
+ asyncStartIndex: i,
+ firstAsyncPromise: result,
+ };
+ }
+ current = result;
+ }
+ return {
+ syncResult: current,
+ asyncStartIndex: enhancers.length,
+ firstAsyncPromise: null,
+ };
}
export interface UseSourceEnhancingProps {
@@ -131,6 +158,46 @@ export interface UseSourceEnhancingResult {
* - Enhancers must return stable references to avoid infinite re-renders.
* - Use `React.useMemo` for the enhancers array to prevent unnecessary re-runs.
*/
+interface AsyncWork {
+ firstAsyncPromise: Promise;
+ asyncStartIndex: number;
+}
+
+interface EnhanceState {
+ enhancedSource: VariantSource | null;
+ asyncWork: AsyncWork | null;
+}
+
+/**
+ * Computes the synchronous enhancement result and any pending async work.
+ * Enhancers are run in order; sync ones apply immediately, and the first
+ * async enhancer's promise is captured so it can be continued in an effect.
+ */
+function computeEnhanceState(
+ source: VariantSource | null | undefined,
+ comments: SourceComments | undefined,
+ fileName: string | undefined,
+ sourceEnhancers: SourceEnhancers | undefined,
+): EnhanceState {
+ if (!source || !sourceEnhancers || sourceEnhancers.length === 0) {
+ return { enhancedSource: source ?? null, asyncWork: null };
+ }
+ const resolved = resolveHastRoot(source);
+ if (!resolved) {
+ return { enhancedSource: source ?? null, asyncWork: null };
+ }
+ const { syncResult, firstAsyncPromise, asyncStartIndex } = applyEnhancersUntilAsync(
+ resolved,
+ comments,
+ fileName || 'unknown',
+ sourceEnhancers,
+ );
+ return {
+ enhancedSource: syncResult,
+ asyncWork: firstAsyncPromise ? { firstAsyncPromise, asyncStartIndex } : null,
+ };
+}
+
export function useSourceEnhancing({
source,
fileName,
@@ -140,73 +207,73 @@ export function useSourceEnhancing({
// Track previous values to detect changes
const [prevSource, setPrevSource] = React.useState(source);
const [prevEnhancers, setPrevEnhancers] = React.useState(sourceEnhancers);
+ const [prevComments, setPrevComments] = React.useState(comments);
+ const [prevFileName, setPrevFileName] = React.useState(fileName);
- // Start with the original source - show it immediately while enhancing
- const [enhancedSource, setEnhancedSource] = React.useState(source ?? null);
- const [isEnhancing, setIsEnhancing] = React.useState(
- () => !!sourceEnhancers && sourceEnhancers.length > 0 && !!source,
+ const [state, setState] = React.useState(() =>
+ computeEnhanceState(source, comments, fileName, sourceEnhancers),
);
- // When source changes, immediately show the new unenhanced source
- // This prevents layout shift while enhancement runs in background
- if (source !== prevSource) {
- setPrevSource(source);
- setEnhancedSource(source ?? null);
- if (sourceEnhancers && sourceEnhancers.length > 0 && source) {
- setIsEnhancing(true);
- }
- }
+ const hasChanged =
+ source !== prevSource ||
+ sourceEnhancers !== prevEnhancers ||
+ comments !== prevComments ||
+ fileName !== prevFileName;
- // Track if enhancers changed
- if (sourceEnhancers !== prevEnhancers) {
- setPrevEnhancers(sourceEnhancers);
- if (sourceEnhancers && sourceEnhancers.length > 0 && source) {
- setIsEnhancing(true);
+ // When inputs change, apply sync enhancers immediately during render
+ if (hasChanged) {
+ if (source !== prevSource) {
+ setPrevSource(source);
+ }
+ if (sourceEnhancers !== prevEnhancers) {
+ setPrevEnhancers(sourceEnhancers);
+ }
+ if (comments !== prevComments) {
+ setPrevComments(comments);
}
+ if (fileName !== prevFileName) {
+ setPrevFileName(fileName);
+ }
+ setState(computeEnhanceState(source, comments, fileName, sourceEnhancers));
}
+ // Continue from the first async enhancer without re-running sync ones
React.useEffect(() => {
- // If no source or no enhancers, just use original
- if (!source || !sourceEnhancers || sourceEnhancers.length === 0) {
- setEnhancedSource(source ?? null);
- setIsEnhancing(false);
- return undefined;
- }
-
- const resolvedHastRoot = resolveHastRoot(source);
- if (!resolvedHastRoot) {
- // Can't enhance non-HAST sources
- setEnhancedSource(source);
- setIsEnhancing(false);
+ if (!state.asyncWork || !sourceEnhancers) {
return undefined;
}
- // Capture values for async function
+ const { firstAsyncPromise, asyncStartIndex } = state.asyncWork;
const enhancers = sourceEnhancers;
const name = fileName || 'unknown';
- const hastRoot = resolvedHastRoot;
let cancelled = false;
- async function enhance() {
- setIsEnhancing(true);
-
- const enhanced = await applyEnhancers(hastRoot, comments, name, enhancers);
-
+ async function continueEnhancing() {
+ const asyncResult = await firstAsyncPromise;
+ if (cancelled) {
+ return;
+ }
+ const final = await applyEnhancersFrom(
+ asyncResult,
+ comments,
+ name,
+ enhancers,
+ asyncStartIndex + 1,
+ );
if (!cancelled) {
- setEnhancedSource(enhanced);
- setIsEnhancing(false);
+ setState({ enhancedSource: final, asyncWork: null });
}
}
- enhance();
+ continueEnhancing();
return () => {
cancelled = true;
};
- }, [source, fileName, comments, sourceEnhancers]);
+ }, [state.asyncWork, sourceEnhancers, fileName, comments]);
return {
- enhancedSource,
- isEnhancing,
+ enhancedSource: state.enhancedSource,
+ isEnhancing: state.asyncWork !== null,
};
}
diff --git a/packages/docs-infra/src/useDemo/useDemo.ts b/packages/docs-infra/src/useDemo/useDemo.ts
index 3ff7c7403..2120a4ed8 100644
--- a/packages/docs-infra/src/useDemo/useDemo.ts
+++ b/packages/docs-infra/src/useDemo/useDemo.ts
@@ -107,10 +107,12 @@ export function useDemo(contentProps: ContentProps, opts?:
return effectiveComponents[code.selectedVariant] || null;
}, [effectiveComponents, code.selectedVariant]);
- // Demo-specific ref and focus management
- const ref = React.useRef(null);
+ // Demo-specific ref and focus management. Typed as `HTMLButtonElement` since
+ // the typical pattern is an invisible focus-target button rendered inside the
+ // demo. `resetFocus` simply calls `.focus()` on it.
+ const focusRef = React.useRef(null);
const resetFocus = React.useCallback(() => {
- ref.current?.focus();
+ focusRef.current?.focus();
}, []);
// Get the effective code - context overrides contentProps if available
@@ -222,7 +224,7 @@ export function useDemo(contentProps: ContentProps, opts?:
...code,
// Demo-specific additions
component,
- ref,
+ focusRef,
resetFocus,
openStackBlitz,
openCodeSandbox,
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 387c052fa..1ff831350 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -39,7 +39,7 @@
"vitest-fail-on-console": "^0.10.1"
},
"devDependencies": {
- "@playwright/test": "1.58.2",
+ "@playwright/test": "1.59.1",
"@types/chai": "5.2.3",
"@types/format-util": "1.0.4",
"@types/jsdom": "28.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1bb55ff66..e91750fda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,11 +84,14 @@ importers:
version: 4.21.0
vitest:
specifier: ^4.1.5
- version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
devDependencies:
'@babel/plugin-transform-react-constant-elements':
specifier: 7.27.1
version: 7.27.1(@babel/core@7.29.0)
+ '@vitest/browser-playwright':
+ specifier: 4.1.3
+ version: 4.1.3(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
baseline-browser-mapping:
specifier: 2.10.19
version: 2.10.19
@@ -102,8 +105,8 @@ importers:
specifier: 0.0.66
version: 0.0.66
playwright:
- specifier: 1.58.2
- version: 1.58.2
+ specifier: 1.59.1
+ version: 1.59.1
stylelint:
specifier: 17.4.0
version: 17.4.0(typescript@6.0.2)
@@ -133,7 +136,7 @@ importers:
version: 9.0.0-alpha.2(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@mui/material-nextjs':
specifier: 9.0.0-alpha.2
- version: 9.0.0-alpha.2(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/server@11.11.0)(@types/react@19.2.14)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
+ version: 9.0.0-alpha.2(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/server@11.11.0)(@types/react@19.2.14)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
'@mui/x-charts-pro':
specifier: 9.0.0-alpha.2
version: 9.0.0-alpha.2(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@mui/material@9.0.0-alpha.2(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mui/system@9.0.0-alpha.3(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -175,7 +178,7 @@ importers:
version: 3.20.0(@types/node@22.19.0)
next:
specifier: ^16.1.6
- version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
pako:
specifier: ^2.1.0
version: 2.1.0
@@ -294,7 +297,7 @@ importers:
version: 0.577.0(react@19.2.5)
next:
specifier: ^16.1.6
- version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
specifier: ^19.2.5
version: 19.2.5
@@ -436,7 +439,7 @@ importers:
version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
'@vitest/browser-playwright':
specifier: '>=4.1'
- version: 4.1.5(playwright@1.58.2)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
+ version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
env-ci:
specifier: ^11.2.0
version: 11.2.0
@@ -873,12 +876,18 @@ importers:
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
+ use-editable:
+ specifier: ^2.3.3
+ version: 2.3.3(react@19.2.5)
vscode-oniguruma:
specifier: ^2.0.1
version: 2.0.1
yargs:
specifier: ^18.0.0
version: 18.0.0
+ zx:
+ specifier: ^8.8.5
+ version: 8.8.5
devDependencies:
'@testing-library/react':
specifier: 16.3.2
@@ -907,6 +916,9 @@ importers:
'@types/react':
specifier: 19.2.14
version: 19.2.14
+ '@types/react-dom':
+ specifier: 19.2.3
+ version: 19.2.3(@types/react@19.2.14)
'@types/webpack':
specifier: 5.28.5
version: 5.28.5(esbuild@0.27.1)(jiti@2.6.1)
@@ -924,10 +936,13 @@ importers:
version: 9.0.5
next:
specifier: 16.2.3
- version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
specifier: 19.2.5
version: 19.2.5
+ react-dom:
+ specifier: 19.2.5
+ version: 19.2.5(react@19.2.5)
rehype-parse:
specifier: 9.0.1
version: 9.0.1
@@ -1000,8 +1015,8 @@ importers:
version: 0.10.1(@vitest/utils@4.1.5)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
devDependencies:
'@playwright/test':
- specifier: 1.58.2
- version: 1.58.2
+ specifier: 1.59.1
+ version: 1.59.1
'@types/chai':
specifier: 5.2.3
version: 5.2.3
@@ -1052,7 +1067,7 @@ importers:
version: 19.2.14
'@vitest/browser-playwright':
specifier: 4.1.5
- version: 4.1.5(playwright@1.58.2)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
+ version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
vitest:
specifier: 4.1.5
version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
@@ -4568,8 +4583,8 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
- '@playwright/test@1.58.2':
- resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ '@playwright/test@1.59.1':
+ resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
@@ -5751,12 +5766,23 @@ packages:
babel-plugin-react-compiler:
optional: true
+ '@vitest/browser-playwright@4.1.3':
+ resolution: {integrity: sha512-D3Q+YozvSpiFaLPgd6/OMbyqEIZeucSe6AHJJ7VnNJKQhIyBE60TlBZlxzwM8bvjzQE9ZnYWQCPeCw5pnhbiNg==}
+ peerDependencies:
+ playwright: '*'
+ vitest: 4.1.3
+
'@vitest/browser-playwright@4.1.5':
resolution: {integrity: sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==}
peerDependencies:
playwright: '*'
vitest: 4.1.5
+ '@vitest/browser@4.1.3':
+ resolution: {integrity: sha512-CS9KjO2vijuBlbwz0JIgC0YuoI1BuqWI5ziD3Nll6jkpNYtWdjPMVgGynQ9vZovjsECeUqEeNjWrypP414d0CQ==}
+ peerDependencies:
+ vitest: 4.1.3
+
'@vitest/browser@4.1.5':
resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==}
peerDependencies:
@@ -5787,6 +5813,17 @@ packages:
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
+ '@vitest/mocker@4.1.3':
+ resolution: {integrity: sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@4.1.5':
resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==}
peerDependencies:
@@ -5798,6 +5835,9 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@4.1.3':
+ resolution: {integrity: sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==}
+
'@vitest/pretty-format@4.1.5':
resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==}
@@ -5807,9 +5847,15 @@ packages:
'@vitest/snapshot@4.1.5':
resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==}
+ '@vitest/spy@4.1.3':
+ resolution: {integrity: sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==}
+
'@vitest/spy@4.1.5':
resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==}
+ '@vitest/utils@4.1.3':
+ resolution: {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==}
+
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
@@ -10107,13 +10153,13 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
- playwright-core@1.58.2:
- resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ playwright-core@1.59.1:
+ resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
- playwright@1.58.2:
- resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ playwright@1.59.1:
+ resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
@@ -12144,6 +12190,11 @@ packages:
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+ zx@8.8.5:
+ resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==}
+ engines: {node: '>= 12.17.0'}
+ hasBin: true
+
snapshots:
'@-xun/debug@2.0.2':
@@ -14509,11 +14560,11 @@ snapshots:
'@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)
'@types/react': 19.2.14
- '@mui/material-nextjs@9.0.0-alpha.2(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/server@11.11.0)(@types/react@19.2.14)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
+ '@mui/material-nextjs@9.0.0-alpha.2(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/server@11.11.0)(@types/react@19.2.14)(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
dependencies:
'@babel/runtime': 7.29.2
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5)
- next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@emotion/cache': 11.14.0
@@ -16148,9 +16199,9 @@ snapshots:
'@pkgr/core@0.2.9': {}
- '@playwright/test@1.58.2':
+ '@playwright/test@1.59.1':
dependencies:
- playwright: 1.58.2
+ playwright: 1.59.1
'@pnpm/config.env-replace@1.1.0': {}
@@ -17667,11 +17718,24 @@ snapshots:
optionalDependencies:
babel-plugin-react-compiler: 1.0.0
- '@vitest/browser-playwright@4.1.5(playwright@1.58.2)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)':
+ '@vitest/browser-playwright@4.1.3(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)':
+ dependencies:
+ '@vitest/browser': 4.1.3(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
+ '@vitest/mocker': 4.1.3(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ playwright: 1.59.1
+ tinyrainbow: 3.1.0
+ vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)':
dependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
- playwright: 1.58.2
+ playwright: 1.59.1
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
transitivePeerDependencies:
@@ -17680,6 +17744,23 @@ snapshots:
- utf-8-validate
- vite
+ '@vitest/browser@4.1.3(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)':
+ dependencies:
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.3(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ '@vitest/utils': 4.1.3
+ magic-string: 0.30.21
+ pngjs: 7.0.0
+ sirv: 3.0.2
+ tinyrainbow: 3.1.0
+ vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ ws: 8.19.0
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
'@vitest/browser@4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)':
dependencies:
'@blazediff/core': 1.9.1
@@ -17689,7 +17770,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
- vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -17709,7 +17790,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
- vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
optionalDependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
@@ -17733,6 +17814,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
+ '@vitest/mocker@4.1.3(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))':
+ dependencies:
+ '@vitest/spy': 4.1.3
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
+
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 4.1.5
@@ -17741,6 +17830,10 @@ snapshots:
optionalDependencies:
vite: 8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
+ '@vitest/pretty-format@4.1.3':
+ dependencies:
+ tinyrainbow: 3.1.0
+
'@vitest/pretty-format@4.1.5':
dependencies:
tinyrainbow: 3.1.0
@@ -17757,8 +17850,16 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@4.1.3': {}
+
'@vitest/spy@4.1.5': {}
+ '@vitest/utils@4.1.3':
+ dependencies:
+ '@vitest/pretty-format': 4.1.3
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
'@vitest/utils@4.1.5':
dependencies:
'@vitest/pretty-format': 4.1.5
@@ -22350,7 +22451,7 @@ snapshots:
neo-async@2.6.2: {}
- next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@next/env': 16.2.3
'@swc/helpers': 0.5.15
@@ -22370,7 +22471,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.2.3
'@next/swc-win32-x64-msvc': 16.2.3
'@opentelemetry/api': 1.9.0
- '@playwright/test': 1.58.2
+ '@playwright/test': 1.59.1
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
@@ -23030,11 +23131,11 @@ snapshots:
dependencies:
find-up: 3.0.0
- playwright-core@1.58.2: {}
+ playwright-core@1.59.1: {}
- playwright@1.58.2:
+ playwright@1.59.1:
dependencies:
- playwright-core: 1.58.2
+ playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
@@ -24965,6 +25066,37 @@ snapshots:
vite: 8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
+ dependencies:
+ '@vitest/expect': 4.1.5
+ '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
+ '@vitest/pretty-format': 4.1.5
+ '@vitest/runner': 4.1.5
+ '@vitest/snapshot': 4.1.5
+ '@vitest/spy': 4.1.5
+ '@vitest/utils': 4.1.5
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.1.1
+ tinyglobby: 0.2.16
+ tinyrainbow: 3.1.0
+ vite: 8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ '@types/node': 22.19.0
+ '@vitest/browser-playwright': 4.1.3(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
+ '@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)
+ jsdom: 28.1.0
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
dependencies:
'@vitest/expect': 4.1.5
@@ -24990,7 +25122,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 22.19.0
- '@vitest/browser-playwright': 4.1.5(playwright@1.58.2)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
+ '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@22.19.0)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))(vitest@4.1.5)
'@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)
jsdom: 28.1.0
transitivePeerDependencies:
@@ -25343,3 +25475,5 @@ snapshots:
zod@4.3.6: {}
zwitch@2.0.4: {}
+
+ zx@8.8.5: {}
diff --git a/vitest.config.browser.mts b/vitest.config.browser.mts
new file mode 100644
index 000000000..8596f384b
--- /dev/null
+++ b/vitest.config.browser.mts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vitest/config';
+import { playwright } from '@vitest/browser-playwright';
+
+const wsEndpoint = process.env.PLAYWRIGHT_SERVER;
+
+export default defineConfig({
+ test: {
+ include: ['packages/**/*.browser.{ts,tsx}'],
+ browser: {
+ enabled: true,
+ provider: playwright({
+ ...(wsEndpoint ? { connectOptions: { wsEndpoint } } : {}),
+ }),
+ headless: true,
+ instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }],
+ },
+ },
+});