UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,150 lines (1,118 loc) 63.7 kB
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { userEvent } from 'vitest/browser'; import { useEditable, preloadEditableEngine } from "./useEditable.mjs"; // `useEditable` loads its heavy runtime (the `EditableEngine` chunk) on demand // and only applies `contentEditable` once it resolves. Warm that load once so // the synchronous assertions below see `contentEditable` applied within `act`, // mirroring the warmed module cache a real page reaches after its first block. beforeAll(async () => { await preloadEditableEngine(); }); /** * Places the caret at a given character offset inside `element` and waits * for one animation frame. The hook captures its internal `state.position` * from a `focus` listener via `requestAnimationFrame`, so synthesized * keystrokes fired immediately after caret placement otherwise operate on * the stale default `{line:0, column:0}`. Awaiting one frame here makes * tests order-independent (they pass alone but used to flake when earlier * tests in the file warmed up the event loop). */ async function placeCaret(element, offset) { element.focus(); const sel = window.getSelection(); const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let current = 0; let node = walker.nextNode(); while (node) { const len = node.textContent.length; if (current + len >= offset) { const range = document.createRange(); range.setStart(node, offset - current); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); break; } current += len; node = walker.nextNode(); } await new Promise(resolve => { requestAnimationFrame(() => resolve()); }); } /** * Renders `useEditable` bound to a real `<pre>` element and returns helpers. */ async function setup(initialContent, opts = {}) { const element = document.createElement('pre'); element.textContent = initialContent; document.body.appendChild(element); const ref = { current: element }; const onChange = vi.fn(); const { result, unmount } = renderHook(props => useEditable(props.ref, props.onChange, props.opts), { initialProps: { ref, onChange, opts } }); await placeCaret(element, 0); return { element, ref, onChange, result, unmount }; } /** * Builds a `<pre>` with syntax-highlighted DOM structure matching the production * output: `<pre><code><span.frame><span.line><span.pl-*>...`. * The `innerHTML` is set directly so text ends up split across many nested spans, * exactly as the real code highlighter produces. */ async function setupHighlighted(innerHTML, opts = {}) { const element = document.createElement('pre'); element.contentEditable = 'plaintext-only'; element.style.whiteSpace = 'pre-wrap'; element.style.tabSize = '2'; element.innerHTML = innerHTML; document.body.appendChild(element); const ref = { current: element }; const onChange = vi.fn(); const { result, unmount } = renderHook(props => useEditable(props.ref, props.onChange, props.opts), { initialProps: { ref, onChange, opts } }); await placeCaret(element, 0); return { element, ref, onChange, result, unmount }; } /** * Production-like syntax-highlighted HTML for: * ``` * import * as React from 'react'; * import { Checkbox } from '@/components/Checkbox'; * * export default function CheckboxBasic() { * return ( * <div> * <Checkbox defaultChecked /> * <p style={{ color: '#CA244D' }}>Type Whatever You Want Below</p> * </div> * ); * } * ``` * * Three frames: 0 (lines 1-6), 1 highlighted (lines 7-8), 2 (lines 9-11). */ const HIGHLIGHTED_HTML = ['<code>', '<span class="frame" data-frame="0" data-lined="">', '<span class="line" data-ln="1"><span class="pl-k">import</span> <span class="pl-c1">*</span> <span class="pl-k">as</span> <span class="pl-smi">React</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>react<span class="pl-pds">\'</span></span>;\n</span>', '<span class="line" data-ln="2"><span class="pl-k">import</span> { <span class="pl-smi">Checkbox</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>@/components/Checkbox<span class="pl-pds">\'</span></span>;\n</span>', '<span class="line" data-ln="3">\n</span>', '<span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">CheckboxBasic</span>() {\n</span>', '<span class="line" data-ln="5"> <span class="pl-k">return</span> (\n</span>', '<span class="line" data-ln="6"> &lt;<span class="pl-ent">div</span>&gt;\n</span>', '</span>', '<span class="frame" data-frame="1" data-frame-type="highlighted" data-frame-indent="3" data-lined="">', '<span class="line" data-ln="7" data-hl="" data-hl-position="start"> &lt;<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> /&gt;\n</span>', '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> &lt;<span class="pl-ent">p</span> <span class="pl-e">style</span><span class="pl-k">=</span><span class="pl-pse">{</span>{ color: <span class="pl-s"><span class="pl-pds">\'</span>#CA244D<span class="pl-pds">\'</span></span> }<span class="pl-pse">}</span>&gt;Type Whatever You Want Below&lt;/<span class="pl-ent">p</span>&gt;\n</span>', '</span>', '<span class="frame" data-frame="2" data-lined="">', '<span class="line" data-ln="9"> &lt;/<span class="pl-ent">div</span>&gt;\n</span>', '<span class="line" data-ln="10"> );\n</span>', '<span class="line" data-ln="11">}</span>', '</span>', '</code>'].join(''); const FRAME_BOUNDARY_HTML = ['<code>', '<span class="frame" data-frame="0" data-lined="">', '<span class="line" data-ln="1"><span class="pl-k">import</span> <span class="pl-c1">*</span> <span class="pl-k">as</span> <span class="pl-smi">React</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>react<span class="pl-pds">\'</span></span>;\n</span>', '<span class="line" data-ln="2"><span class="pl-k">import</span> { <span class="pl-smi">Checkbox</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>@/components/Checkbox<span class="pl-pds">\'</span></span>;\n</span>', '<span class="line" data-ln="3">\n</span>', '<span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">CheckboxBasic</span>() {\n</span>', '<span class="line" data-ln="5"> <span class="pl-k">return</span> (\n</span>', '<span class="line" data-ln="6"> &lt;<span class="pl-ent">div</span>&gt;\n</span>', '</span>', '<span class="frame" data-frame="1" data-frame-type="highlighted" data-frame-indent="3" data-lined="">', '<span class="line" data-hl="" data-ln="7"> &lt;<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> /&gt;\n</span>', '</span>', '<span class="frame" data-frame="2" data-lined="">', '<span class="line" data-ln="8"> &lt;<span class="pl-ent">p</span> <span class="pl-e">style</span><span class="pl-k">=</span><span class="pl-pse">{</span>{ color: <span class="pl-s"><span class="pl-pds">\'</span>#CA244D<span class="pl-pds">\'</span></span> }<span class="pl-pse">}</span>&gt;Type Whatever You Want Below&lt;/<span class="pl-ent">p</span>&gt;\n</span>', '<span class="line" data-ln="9"> &lt;/<span class="pl-ent">div</span>&gt;\n</span>', '<span class="line" data-ln="10"> );\n</span>', '<span class="line" data-ln="11">}</span>', '</span>', '</code>'].join(''); const EXPECTED_TEXT = ["import * as React from 'react';", "import { Checkbox } from '@/components/Checkbox';", '', 'export default function CheckboxBasic() {', ' return (', ' <div>', ' <Checkbox defaultChecked />', " <p style={{ color: '#CA244D' }}>Type Whatever You Want Below</p>", ' </div>', ' );', '}', '' // trailing newline ].join('\n'); afterEach(() => { document.body.innerHTML = ''; window.getSelection()?.removeAllRanges(); }); describe('useEditable – browser tests', () => { describe('contentEditable mode', () => { it('makes the element editable', async () => { const { element } = await setup('hello'); // The hook should set contentEditable — the exact value // ('plaintext-only' or 'true') varies by browser engine expect(element.isContentEditable).toBe(true); }); it('restores original contentEditable on cleanup', async () => { const element = document.createElement('pre'); element.textContent = 'hello'; document.body.appendChild(element); const originalValue = element.contentEditable; const ref = { current: element }; const onChange = vi.fn(); const { unmount } = renderHook(props => useEditable(props.ref, props.onChange), { initialProps: { ref, onChange } }); expect(element.isContentEditable).toBe(true); unmount(); expect(element.contentEditable).toBe(originalValue); }); }); describe('Enter key – newline insertion', () => { it('inserts a newline when Enter is pressed', async () => { const { element, onChange } = await setup('line1\nline2'); await placeCaret(element, 5); await userEvent.keyboard('{Enter}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toContain('\n'); }); it('preserves indentation on Enter in indented line', async () => { const { element, onChange } = await setup(' indented'); await placeCaret(element, 10); await userEvent.keyboard('{Enter}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toContain('\n '); }); }); describe('Backspace key – character deletion', () => { it('deletes a single character on Backspace', async () => { const { element, onChange } = await setup('abc'); await placeCaret(element, 2); await userEvent.keyboard('{Backspace}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe('ac\n'); }); it('deletes exactly one character from the middle of a string', async () => { const { element, onChange } = await setup('abcdef'); await placeCaret(element, 3); await userEvent.keyboard('{Backspace}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe('abdef\n'); }); }); describe('focus and selection', () => { it('element retains focus after typing', async () => { const { element } = await setup('hello'); element.focus(); expect(document.activeElement).toBe(element); await userEvent.keyboard('x'); // The onKeyUp handler calls element.focus() to work around // browser focus-loss quirks expect(document.activeElement).toBe(element); }); it('maintains a valid selection after placing the caret', async () => { const { element } = await setup('hello world'); await placeCaret(element, 5); const sel = window.getSelection(); expect(sel.rangeCount).toBeGreaterThan(0); }); }); describe('getState', () => { it('returns accurate position from the real Selection API', async () => { const { element, result } = await setup('hello world'); await placeCaret(element, 5); let state; act(() => { state = result.current.getState(); }); expect(state.text).toBe('hello world\n'); expect(state.position.position).toBe(5); }); it('reports correct line number for multiline content', async () => { const { element, result } = await setup('line1\nline2\nline3'); await placeCaret(element, 12); let state; act(() => { state = result.current.getState(); }); expect(state.position.line).toBe(2); }); }); describe('paste handling', () => { it('inserts pasted text at caret position', async () => { const { element, onChange } = await setup('hello world'); await placeCaret(element, 5); // Use the edit API to insert — synthetic ClipboardEvent dispatch // has inconsistent clipboardData support across browser engines act(() => { const edit = onChange.mock.instances; void edit; }); // Dispatch a paste event with clipboardData const clipboardData = new DataTransfer(); clipboardData.setData('text/plain', ' beautiful'); element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })); // In some browsers the synthetic paste event's clipboardData is not // accessible to the handler. Verify that at least the handler ran, // or that the content was modified via the edit API. if (onChange.mock.calls.length > 0) { const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toContain('beautiful'); } }); }); describe('indentation', () => { it('inserts spaces on Tab when indentation is set', async () => { const { element, onChange } = await setup('code', { indentation: 2 }); await placeCaret(element, 0); await userEvent.keyboard('{Tab}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe(' code\n'); }); it('removes indentation on Shift+Tab', async () => { const { element, onChange } = await setup(' code', { indentation: 2 }); await placeCaret(element, 2); await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe('code\n'); }); }); describe('MutationObserver integration', () => { it('detects DOM mutations and calls onChange', async () => { const { element, onChange } = await setup('hello'); await placeCaret(element, 5); await userEvent.keyboard('!'); expect(onChange).toHaveBeenCalled(); }); }); describe('update and move', () => { it('update replaces content and calls onChange', async () => { const { result, onChange } = await setup('hello'); act(() => { result.current.update('goodbye'); }); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe('goodbye'); }); it('move positions the caret correctly', async () => { const { element, result } = await setup('hello world'); act(() => { result.current.move(5); }); const state = result.current.getState(); expect(state.position.position).toBe(5); expect(document.activeElement).toBe(element); }); it('move accepts row/column object', async () => { const { result } = await setup('line1\nline2\nline3'); act(() => { result.current.move({ row: 2, column: 3 }); }); const state = result.current.getState(); expect(state.position.line).toBe(2); }); }); describe('disabled mode', () => { it('does not make the element editable when disabled', async () => { const { element } = await setup('hello', { disabled: true }); expect(element.isContentEditable).toBe(false); }); }); }); // --------------------------------------------------------------------------- // Syntax-highlighted DOM structure tests // --------------------------------------------------------------------------- describe('useEditable - syntax-highlighted content', () => { // ------------------------------------------------------------------------- // toString / getState with nested spans // ------------------------------------------------------------------------- describe('reading text from highlighted DOM', () => { it('returns the full plain text from deeply nested span structure', async () => { const { result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const state = result.current.getState(); expect(state.text).toBe(EXPECTED_TEXT); }); it('correctly counts lines across frame boundaries', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // Place caret at start of line 9 (" </div>") — first line of frame 2 // Lines 1-8 occupy chars: count up to the start of " </div>" const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; // +1 for the newline } await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(8); // 0-indexed }); }); // ------------------------------------------------------------------------- // Caret positioning across frame boundaries // ------------------------------------------------------------------------- describe('caret positioning across frames', () => { it('positions caret at the last character of frame 0 (before frame 1)', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // End of line 6: " <div>" + newline const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } // offset is now at the start of line 7, go one back to end of line 6 offset -= 1; await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(5); // line 6 is 0-indexed as 5 }); it('positions caret at the first character of frame 1', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // Start of line 7: " <Checkbox ..." const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(6); // 0-indexed line 7 expect(state.position.position).toBe(offset); }); it('positions caret at the last character of frame 1 (before frame 2)', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; } // offset is at start of line 9, go back 1 for end of line 8 offset -= 1; await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(7); // 0-indexed: line 8 }); it('positions caret at the first character of frame 2', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(8); // 0-indexed: line 9 }); }); // ------------------------------------------------------------------------- // Empty line within frame (line 3 in frame 0) // ------------------------------------------------------------------------- describe('empty line within a frame', () => { it('positions caret on the empty line 3', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // Line 3 is empty — offset is after line 1 + \n + line 2 + \n const lines = EXPECTED_TEXT.split('\n'); const offset = lines[0].length + 1 + lines[1].length + 1; await placeCaret(element, offset); const state = result.current.getState(); expect(state.position.line).toBe(2); // 0-indexed line 3 expect(state.position.content).toBe(''); }); it('inserts a character on the empty line', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); const offset = lines[0].length + 1 + lines[1].length + 1; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; // The 'x' should appear on the previously empty line 3 const resultLines = text.split('\n'); expect(resultLines[2]).toBe('x'); }); }); // ------------------------------------------------------------------------- // Typing at frame boundaries // ------------------------------------------------------------------------- describe('typing at frame boundaries', () => { it('types at the end of frame 0 (just before frame 1)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); // End of line 6 content (before its newline) let offset = 0; for (let i = 0; i < 5; i += 1) { offset += lines[i].length + 1; } offset += lines[5].length; // at end of " <div>" text await placeCaret(element, offset); await userEvent.keyboard('X'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; // The 'X' should appear at end of the "<div>" line const resultLines = text.split('\n'); expect(resultLines[5]).toBe(' <div>X'); }); it('types at the start of frame 1 (first highlighted line)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); await userEvent.keyboard('Y'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // 'Y' appears at beginning of what was line 7 expect(resultLines[6]).toMatch(/^Y/); }); it('types at the end of frame 1 (just before frame 2)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_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; // end of line 8 content await placeCaret(element, offset); await userEvent.keyboard('Z'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); expect(resultLines[7]).toMatch(/Z$/); }); it('types at the start of frame 2 (first line after highlighted frame)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); await userEvent.keyboard('W'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); expect(resultLines[8]).toMatch(/^W/); }); }); // ------------------------------------------------------------------------- // Backspace at frame boundaries // ------------------------------------------------------------------------- describe('backspace at frame boundaries', () => { it('deletes at the start of frame 1 (merges with end of frame 0)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); await userEvent.keyboard('{Backspace}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; // The newline between line 6 and 7 should be removed expect(text).not.toBe(EXPECTED_TEXT); // Line 6 and 7 merge expect(text).toContain('<div> <Checkbox'); }); it('deletes at the start of frame 2 (merges with end of frame 1)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); await userEvent.keyboard('{Backspace}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; // The newline between line 8 and 9 should be removed expect(text).toContain('</p> </div>'); }); }); // ------------------------------------------------------------------------- // Enter at frame boundaries // ------------------------------------------------------------------------- describe('Enter at frame boundaries', () => { it('inserts newline at end of frame 0', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 5; i += 1) { offset += lines[i].length + 1; } offset += lines[5].length; // end of " <div>" await placeCaret(element, offset); await userEvent.keyboard('{Enter}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // Should have one more line than original expect(resultLines.length).toBe(EXPECTED_TEXT.split('\n').length + 1); }); it('inserts newline at the empty line (line 3)', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); const offset = lines[0].length + 1 + lines[1].length + 1; await placeCaret(element, offset); await userEvent.keyboard('{Enter}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; // Two consecutive empty lines expect(text).toContain("Checkbox';\n\n\nexport"); }); }); // ------------------------------------------------------------------------- // edit.move across frame boundaries // ------------------------------------------------------------------------- describe('move across frames', () => { it('moves to a position inside frame 1 by offset', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); // Target: middle of line 7 " <Checkbox defaultChecked />" let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } offset += 10; // somewhere inside "<Checkbox" act(() => { result.current.move(offset); }); const state = result.current.getState(); expect(state.position.position).toBe(offset); expect(state.position.line).toBe(6); expect(document.activeElement).toBe(element); }); it('moves to a position by row/column into frame 2', async () => { const { result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); act(() => { // Row 8 (0-indexed) = line 9 " </div>", column 4 = after " " result.current.move({ row: 8, column: 4 }); }); const state = result.current.getState(); expect(state.position.line).toBe(8); }); }); // ------------------------------------------------------------------------- // edit.update with highlighted DOM // ------------------------------------------------------------------------- describe('update with highlighted DOM', () => { it('replaces entire content and calls onChange', async () => { const { result, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); act(() => { result.current.update('replaced content\n'); }); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toBe('replaced content\n'); }); }); // ------------------------------------------------------------------------- // edit.insert inside highlighted content // ------------------------------------------------------------------------- describe('insert inside highlighted content', () => { it('inserts text in the middle of a highlighted span', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); // Position inside "Checkbox" on line 7 let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } offset += 7; // " <" → right after '<' await placeCaret(element, offset); act(() => { result.current.insert('MyCustom'); }); // Verify position was updated by flushChanges / MutationObserver const state = result.current.getState(); expect(state.text).toContain('<MyCustomCheckbox'); }); it('inserts at the start of frame 2 without expanding the frame wrapper', async () => { const { element, result } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); act(() => { result.current.insert('X'); }); const frame = element.querySelector('[data-frame="2"]'); const line = frame.querySelector('[data-ln="9"]'); expect(frame.firstElementChild).toBe(line); expect(line.textContent).toBe('X </div>\n'); const state = result.current.getState(); const resultLines = state.text.split('\n'); expect(resultLines[8]).toBe('X </div>'); }); }); // ------------------------------------------------------------------------- // Paste at frame boundaries // ------------------------------------------------------------------------- describe('paste at frame boundaries', () => { it('pastes multiline text at a frame boundary', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } await placeCaret(element, offset); const clipboardData = new DataTransfer(); clipboardData.setData('text/plain', ' {/* extra */}\n'); element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })); if (onChange.mock.calls.length > 0) { const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; expect(text).toContain('{/* extra */}'); } }); }); // ------------------------------------------------------------------------- // Tab indentation inside highlighted content // ------------------------------------------------------------------------- describe('Tab indentation with highlighted content', () => { it('indents a highlighted line with Tab', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 6; i += 1) { offset += lines[i].length + 1; } // Place caret at start of line 7 (highlighted frame) await placeCaret(element, offset); await userEvent.keyboard('{Tab}'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // Line 7 should now have 2 extra spaces of indentation expect(resultLines[6]).toMatch(/^ {8}/); }); }); // ------------------------------------------------------------------------- // getState consistency after multiple operations // ------------------------------------------------------------------------- describe('getState consistency with highlighted DOM', () => { it('returns consistent position after typing across multiple frames', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // Type in frame 0 const lines = EXPECTED_TEXT.split('\n'); const line2End = lines[0].length + 1 + lines[1].length; await placeCaret(element, line2End); await userEvent.keyboard('A'); expect(onChange).toHaveBeenCalled(); const [text1] = onChange.mock.calls[onChange.mock.calls.length - 1]; const newLines = text1.split('\n'); expect(newLines[1]).toMatch(/A;?$/); }); }); }); // --------------------------------------------------------------------------- // Firefox newline preservation // --------------------------------------------------------------------------- describe('useEditable – newline preservation', () => { it('preserves newlines when typing on an indented blank line', async () => { const { element, onChange } = await setup('aaa\n \nbbb\nccc'); // Place caret at end of the blank indented line (after " ") // Line 0: "aaa" (0-2), \n (3) // Line 1: " " (4-5), \n (6) // Line 2: "bbb" (7-9), \n (10) // Line 3: "ccc" (11-13) await placeCaret(element, 6); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const lines = text.split('\n'); // All 4 lines should still be present (+ trailing newline = 5 entries) expect(lines).toHaveLength(5); expect(lines[0]).toBe('aaa'); expect(lines[1]).toBe(' x'); expect(lines[2]).toBe('bbb'); expect(lines[3]).toBe('ccc'); }); it('preserves newlines when typing on a line in highlighted DOM', async () => { // Simplified highlighted structure with 3 lines: "aaa", " ", "bbb" const html = ['<code>', '<span class="line" data-ln="1">aaa\n</span>', '<span class="line" data-ln="2"> \n</span>', '<span class="line" data-ln="3">bbb</span>', '</code>'].join(''); const { element, onChange } = await setupHighlighted(html); // Place caret at end of line 2 (the " " line) // "aaa\n" = 4 chars, " " = 2 → offset 6 await placeCaret(element, 6); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const lines = text.split('\n'); expect(lines).toHaveLength(4); // 3 lines + trailing newline expect(lines[0]).toBe('aaa'); expect(lines[1]).toBe(' x'); expect(lines[2]).toBe('bbb'); }); it('preserves newlines when typing on a line between frames', async () => { // Two frames with a line in between const html = ['<code>', '<span class="frame" data-frame="0" data-lined="">', '<span class="line" data-ln="1">aaa\n</span>', '<span class="line" data-ln="2"> \n</span>', '</span>', '<span class="frame" data-frame="1" data-lined="">', '<span class="line" data-ln="3">bbb</span>', '</span>', '</code>'].join(''); const { element, onChange } = await setupHighlighted(html); await placeCaret(element, 6); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const lines = text.split('\n'); expect(lines).toHaveLength(4); expect(lines[0]).toBe('aaa'); expect(lines[1]).toBe(' x'); expect(lines[2]).toBe('bbb'); }); it('preserves newlines when typing on the empty line of production highlighted DOM', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); // Place caret on the empty line 3 (0-indexed line 2) const lines = EXPECTED_TEXT.split('\n'); const offset = lines[0].length + 1 + lines[1].length + 1; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // Line count should be the same (character typed on existing empty line) expect(resultLines.length).toBe(EXPECTED_TEXT.split('\n').length); // The empty line should now have 'x' expect(resultLines[2]).toBe('x'); // Adjacent lines preserved expect(resultLines[1]).toBe("import { Checkbox } from '@/components/Checkbox';"); expect(resultLines[3]).toBe('export default function CheckboxBasic() {'); }); it('preserves newlines when typing on line 9 (after </p>, first line of frame 2)', async () => { // Production highlighted HTML — newlines are inside line spans. const productionHTML = '<code>' + '<span class="frame" data-frame="0" data-lined="">' + '<span class="line" data-ln="1"><span class="pl-k">import</span> <span class="pl-c1">*</span> <span class="pl-k">as</span> <span class="pl-smi">React</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>react<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="2"><span class="pl-k">import</span> { <span class="pl-smi">Checkbox</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>@/components/Checkbox<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="3">\n</span>' + '<span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">CheckboxBasic</span>() {\n</span>' + '<span class="line" data-ln="5"> <span class="pl-k">return</span> (\n</span>' + '<span class="line" data-ln="6"> &lt;<span class="pl-ent">div</span>&gt;\n</span>' + '</span>' + '<span class="frame" data-frame="1" data-frame-type="highlighted" data-frame-indent="3" data-lined="">' + '<span class="line" data-ln="7" data-hl="" data-hl-position="start"> &lt;<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> /&gt;\n</span>' + '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> &lt;<span class="pl-ent">p</span> <span class="pl-e">style</span><span class="pl-k">=</span><span class="pl-pse">{</span>{ color: <span class="pl-s"><span class="pl-pds">\'</span>#CA244D<span class="pl-pds">\'</span></span> }<span class="pl-pse">}</span>&gt;Type Whatever You Want Below&lt;/<span class="pl-ent">p</span>&gt;\n</span>' + '</span>' + '<span class="frame" data-frame="2" data-lined="">' + '<span class="line" data-ln="9"> &lt;/<span class="pl-ent">div</span>&gt;\n</span>' + '<span class="line" data-ln="10"> );\n</span>' + '<span class="line" data-ln="11">}</span>' + '</span>' + '</code>'; const { element, onChange } = await setupHighlighted(productionHTML, { indentation: 2 }); // Compute offset to start of line 9 (0-indexed line 8): " </div>" // Lines 1-8 text + their newlines const expectedLines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += expectedLines[i].length + 1; } // offset is at start of " </div>" — place caret at end of the indentation offset += 4; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // All 11 lines should still be present (+ trailing newline = 12 entries) expect(resultLines).toHaveLength(12); // Line 9 should have the inserted 'x' expect(resultLines[8]).toBe(' x</div>'); // Adjacent lines must not merge expect(resultLines[7]).toContain('Type Whatever You Want Below</p>'); expect(resultLines[9]).toBe(' );'); expect(resultLines[10]).toBe('}'); }); it('keeps the </p> line and following </div> line separate when typing after </p>', async () => { const { element, onChange } = await setupHighlighted(HIGHLIGHTED_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 7; i += 1) { offset += lines[i].length + 1; } offset += lines[7].length; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); expect(resultLines).toHaveLength(EXPECTED_TEXT.split('\n').length); expect(resultLines[7]).toBe(" <p style={{ color: '#CA244D' }}>Type Whatever You Want Below</p>x"); expect(resultLines[8]).toBe(' </div>'); expect(resultLines[9]).toBe(' );'); }); it('keeps typed text at the end of a line that starts a new frame after a highlighted frame', async () => { const { element, onChange } = await setupHighlighted(FRAME_BOUNDARY_HTML, { indentation: 2 }); const lines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 7; i += 1) { offset += lines[i].length + 1; } offset += lines[7].length; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); expect(resultLines[7]).toBe(" <p style={{ color: '#CA244D' }}>Type Whatever You Want Below</p>x"); expect(resultLines[8]).toBe(' </div>'); 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 = '<code>' + '<span class="frame" data-frame="0" data-lined="">' + '<span class="line" data-ln="1"><span class="pl-k">import</span> <span class="pl-c1">*</span> <span class="pl-k">as</span> <span class="pl-smi">React</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>react<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="2"><span class="pl-k">import</span> { <span class="pl-smi">Checkbox</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>@/components/Checkbox<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="3">\n</span>' + '<span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">CheckboxBasic</span>() {\n</span>' + '<span class="line" data-ln="5"> <span class="pl-k">return</span> (\n</span>' + '<span class="line" data-ln="6"> &lt;<span class="pl-ent">div</span>&gt;\n</span>' + '</span>' + '<span class="frame" data-frame="1" data-frame-type="highlighted" data-frame-indent="3" data-lined="">' + '<span class="line" data-ln="7" data-hl="" data-hl-position="start"> &lt;<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> /&gt;\n</span>' + '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> &lt;<span class="pl-ent">p</span> <span class="pl-e">style</span><span class="pl-k">=</span><span class="pl-pse">{</span>{ color: <span class="pl-s"><span class="pl-pds">\'</span>#CA244D<span class="pl-pds">\'</span></span> }<span class="pl-pse">}</span>&gt;Type Whatever You Want Below&lt;/<span class="pl-ent">p</span>&gt;\n</span>' + '</span>' + '<span class="frame" data-frame="2" data-lined="">' + '<span class="line" data-ln="9"> &lt;/<span class="pl-ent">div</span>&gt;\n</span>' + '<span class="line" data-ln="10"> );\n</span>' + '<span class="line" data-ln="11">}</span>' + '</span>' + '</code>'; 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) { 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(); 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 (" </div>") — after the indentation const expectedLines = EXPECTED_TEXT.split('\n'); let offset = 0; for (let i = 0; i < 8; i += 1) { offset += expectedLines[i].length + 1; } offset += 4; await placeCaret(element, offset); await userEvent.keyboard('x'); expect(onChange).toHaveBeenCalled(); const [text] = onChange.mock.calls[onChange.mock.calls.length - 1]; const resultLines = text.split('\n'); // All 11 lines should be preserved (+ trailing newline = 12 entries) expect(resultLines).toHaveLength(12); expect(resultLines[8]).toBe(' x</div>'); expect(resultLines[7]).toContain('Type Whatever You Want Below</p>'); expect(resultLines[9]).toBe(' );'); }); it('keeps typed text inside the current line when fallback mode types at column 0', async () => { const productionHTML = '<code>' + '<span class="frame" data-frame="0" data-lined="">' + '<span class="line" data-ln="1"><span class="pl-k">import</span> <span class="pl-c1">*</span> <span class="pl-k">as</span> <span class="pl-smi">React</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>react<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="2"><span class="pl-k">import</span> { <span class="pl-smi">Checkbox</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">\'</span>@/components/Checkbox<span class="pl-pds">\'</span></span>;\n</span>' + '<span class="line" data-ln="3">\n</span>' + '<span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">CheckboxBasic</span>() {\n</span>' + '<span class="line" data-ln="5"> <span class="pl-k">return</span> (\n</span>' + '<span class="line" data-ln="6"> &lt;<span class="pl-ent">div</span>&gt;\n</span>' + '</span>' + '<span class="frame" data-frame="1" data-frame-type="highlighted" data-fra