@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
1,150 lines (1,118 loc) • 63.7 kB
JavaScript
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"> <<span class="pl-ent">div</span>>\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"> <<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> />\n</span>', '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> <<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>>Type Whatever You Want Below</<span class="pl-ent">p</span>>\n</span>', '</span>', '<span class="frame" data-frame="2" data-lined="">', '<span class="line" data-ln="9"> </<span class="pl-ent">div</span>>\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"> <<span class="pl-ent">div</span>>\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"> <<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> />\n</span>', '</span>', '<span class="frame" data-frame="2" data-lined="">', '<span class="line" data-ln="8"> <<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>>Type Whatever You Want Below</<span class="pl-ent">p</span>>\n</span>', '<span class="line" data-ln="9"> </<span class="pl-ent">div</span>>\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"> <<span class="pl-ent">div</span>>\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"> <<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> />\n</span>' + '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> <<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>>Type Whatever You Want Below</<span class="pl-ent">p</span>>\n</span>' + '</span>' + '<span class="frame" data-frame="2" data-lined="">' + '<span class="line" data-ln="9"> </<span class="pl-ent">div</span>>\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"> <<span class="pl-ent">div</span>>\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"> <<span class="pl-c1">Checkbox</span> <span class="pl-e">defaultChecked</span> />\n</span>' + '<span class="line" data-ln="8" data-hl="" data-hl-position="end"> <<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>>Type Whatever You Want Below</<span class="pl-ent">p</span>>\n</span>' + '</span>' + '<span class="frame" data-frame="2" data-lined="">' + '<span class="line" data-ln="9"> </<span class="pl-ent">div</span>>\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"> <<span class="pl-ent">div</span>>\n</span>' + '</span>' + '<span class="frame" data-frame="1" data-frame-type="highlighted" data-fra