@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
870 lines (835 loc) • 32.8 kB
JavaScript
var _style;
/**
* Browser integration tests for `useEditable`: the full type → flush → re-highlight →
* restore cycle the docs live code editor runs on every keystroke. They cover the
* behaviors that only emerge when the highlighted DOM is replaced underneath the caret —
* caret stability while typing and deleting indents, scroll-anchor `onBoundary` firing at
* the visible top/bottom, focus retention on the first keystroke, and a selection's
* direction surviving a re-render.
*
* Unlike `useEditable.browser.ts` (which drives a static highlighted DOM with a `vi.fn()`
* onChange and only asserts the *text* handed to onChange), these tests wire `useEditable`
* to a real React component whose `onChange` updates source state, re-highlights it into
* the production `.line`/`pl-*` span structure, and lets the engine's `observeAndRestore`
* restore the caret.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
import { userEvent } from 'vitest/browser';
import { useEditable, preloadEditableEngine } from "./useEditable.mjs";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
beforeAll(async () => {
await preloadEditableEngine();
});
afterEach(() => {
cleanup();
document.body.innerHTML = '';
window.getSelection()?.removeAllRanges();
});
// ---------------------------------------------------------------------------
// Minimal, deterministic re-highlighter
// ---------------------------------------------------------------------------
// Splits each line into tokens and wraps identifiers (`pl-smi`) and single-char
// operators (`pl-k`) in their own spans, leaving whitespace/punctuation as bare
// text. This reproduces the multi-span-per-line DOM shape that production
// highlighting produces — enough to surface caret-at-element-boundary bugs —
// without pulling in the async grammar/worker pipeline. Each line span keeps its
// trailing `\n` inside it, matching the production serializer.
const WORD = /[A-Za-z0-9_$]/;
const OPERATOR = new Set(['=', '+', '-', '*', '/', '<', '>', '!', '&', '|', '%', '?', ':']);
function highlightLine(lineText) {
const nodes = [];
let i = 0;
let key = 0;
while (i < lineText.length) {
const char = lineText[i];
if (WORD.test(char)) {
let j = i + 1;
while (j < lineText.length && WORD.test(lineText[j])) {
j += 1;
}
nodes.push(/*#__PURE__*/_jsx("span", {
className: "pl-smi",
children: lineText.slice(i, j)
}, key));
key += 1;
i = j;
} else if (OPERATOR.has(char)) {
nodes.push(/*#__PURE__*/_jsx("span", {
className: "pl-k",
children: char
}, key));
key += 1;
i += 1;
} else {
// Coalesce a run of bare characters (whitespace/punctuation) into one
// text node, as the highlighter would.
let j = i + 1;
while (j < lineText.length && !WORD.test(lineText[j]) && !OPERATOR.has(lineText[j])) {
j += 1;
}
nodes.push(lineText.slice(i, j));
i = j;
}
}
return nodes;
}
function Highlighted({
source
}) {
// Mirror the production live-editing structure: `.line` spans live inside a
// `.frame`, and the newline after each line is a SEPARATE sibling text node
// ("trailing newline" / gap node), NOT kept inside the line span. These gap
// nodes are exactly what `caretSelector` + `snapCaretOutOfGapNode` exist for.
// `source` always carries a trailing newline.
const withoutTrailing = source.endsWith('\n') ? source.slice(0, -1) : source;
const lines = withoutTrailing.split('\n');
return /*#__PURE__*/_jsx("code", {
children: /*#__PURE__*/_jsx("span", {
className: "frame",
"data-frame": "0",
"data-lined": "",
children: lines.map((line, index) => /*#__PURE__*/_jsxs(React.Fragment, {
children: [/*#__PURE__*/_jsx("span", {
className: "line",
"data-ln": index + 1,
children: highlightLine(line)
}), '\n']
}, index))
})
});
}
function Editor({
initialSource,
options,
handleRef,
scroll
}) {
const [source, setSource] = React.useState(initialSource);
const [, forceRerender] = React.useReducer(count => count + 1, 0);
const ref = React.useRef(null);
const scrollRef = React.useRef(null);
const onChange = React.useMemo(() => vi.fn((text, _position) => {
setSource(text);
}), []);
useEditable(ref, onChange, options);
React.useEffect(() => {
handleRef.current = {
ref,
onChange,
getSource: () => onChange.mock.calls.length ? onChange.mock.calls[onChange.mock.calls.length - 1][0] : initialSource,
scrollEl: scrollRef.current,
rerender: forceRerender
};
});
const editor = /*#__PURE__*/_jsxs(React.Fragment, {
children: [_style || (_style = /*#__PURE__*/_jsx("style", {
children: `
[data-testid="editor"] { line-height: 1.5; font-family: monospace; }
[data-testid="editor"] .frame[data-lined] { display: block; white-space: normal; line-height: 0; }
[data-testid="editor"] .frame[data-lined] .line { display: block; white-space: pre; line-height: initial; }
`
})), /*#__PURE__*/_jsx("pre", {
ref: ref,
style: {
tabSize: 2,
margin: 0
},
"data-testid": "editor",
children: /*#__PURE__*/_jsx(Highlighted, {
source: source
})
})]
});
if (!scroll) {
return editor;
}
// A short, scrollable viewport so we can detect "the view jumps" by reading
// scrollTop before/after an edit.
return /*#__PURE__*/_jsx("div", {
ref: scrollRef,
style: {
height: '60px',
overflow: 'auto',
fontFamily: 'monospace',
lineHeight: '20px'
},
"data-testid": "scroll",
children: editor
});
}
/** Renders the editor and returns a handle once the engine has attached. */
async function setupEditor(initialSource, options = {}, opts = {}) {
const handleRef = {
current: null
};
render(/*#__PURE__*/_jsx(Editor, {
initialSource: initialSource,
options: options,
handleRef: handleRef,
scroll: opts.scroll
}));
// Wait a frame for the layout effects (engine attach + contentEditable).
await act(async () => {
await new Promise(resolve => {
requestAnimationFrame(() => resolve());
});
});
const handle = handleRef.current;
const element = handle.ref.current;
return {
handle,
element
};
}
/**
* Places the caret at an absolute character offset (counting newlines that live
* inside `.line` spans) and waits a frame so the engine captures the position.
*/
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 act(async () => {
await new Promise(resolve => {
requestAnimationFrame(() => resolve());
});
});
}
/** Computes the caret's (line, column) from the live Selection. */
function caretLineColumn(element) {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
const until = document.createRange();
until.setStart(element, 0);
until.setEnd(range.startContainer, range.startOffset);
const text = until.toString();
const lines = text.split('\n');
return {
line: lines.length - 1,
column: lines[lines.length - 1].length,
position: text.length
};
}
/** Lets queued microtasks / rAF callbacks / React effects settle. */
async function settle() {
await act(async () => {
await new Promise(resolve => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
});
}
/** Builds an async preParse that re-highlights on a macrotask (worker-like). */
function asyncPreParse() {
return vi.fn((_text, _position) => new Promise(resolve => {
setTimeout(() => resolve(undefined), 0);
}));
}
/**
* A preParse whose resolution is controlled by the test — models the worker
* round-trip so we can inspect the DOM *during* the async gap (before the
* re-highlight commits) the way the user sees "the wrong thing for a second".
*/
function deferredPreParse() {
let resolveFn = null;
const preParse = vi.fn((_text, _position) => new Promise(resolve => {
resolveFn = () => resolve(undefined);
}));
return {
preParse,
resolvePending: async () => {
resolveFn?.();
resolveFn = null;
await settle();
}
};
}
describe('useEditable — caret & selection across re-highlights', () => {
// -------------------------------------------------------------------------
// Caret stability when typing x, =, Backspace (must not jump to column 0)
// -------------------------------------------------------------------------
it('keeps the caret after the typed char when typing x, =, Backspace on a fresh line', async () => {
const {
handle,
element
} = await setupEditor('\n', {
indentation: 2,
caretSelector: '.line'
});
await placeCaret(element, 0);
await userEvent.keyboard('x');
await settle();
await userEvent.keyboard('=');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('x\n');
expect(caretLineColumn(element)).toMatchObject({
line: 0,
column: 1
});
});
it('keeps the caret after the typed char through an async re-highlight (preParse)', async () => {
const {
handle,
element
} = await setupEditor('\n', {
indentation: 2,
caretSelector: '.line',
preParse: asyncPreParse()
});
await placeCaret(element, 0);
await userEvent.keyboard('x');
await settle();
await settle();
await userEvent.keyboard('=');
await settle();
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
await settle();
expect(handle.getSource()).toBe('x\n');
expect(caretLineColumn(element)).toMatchObject({
line: 0,
column: 1
});
});
it('keeps the caret when typing = then Backspace at the end of an indented line', async () => {
// ` <p style=` then backspace the `=`. This mirrors typing an attribute.
const initial = 'function App() {\n return <p style\n}\n';
const {
handle,
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line'
});
// caret at end of ` return <p style` (line 1)
const offset = 'function App() {\n return <p style'.length;
await placeCaret(element, offset);
await userEvent.keyboard('=');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe(initial);
// caret should be back at end of `style` (column 17), not column 0
expect(caretLineColumn(element)).toMatchObject({
line: 1,
column: ' return <p style'.length
});
});
it('keeps the caret at the end of an indented line in a collapsed (minColumn) gutter', async () => {
// The collapsed editor (clipped indent gutter) is where the real bug shows.
const initial = 'function foo() {\n doStuff()\n}\n';
const {
handle,
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minColumn: 2,
minRow: 1,
maxRow: 1,
onBoundary: vi.fn()
});
const offset = 'function foo() {\n doStuff()'.length; // end of line 1
await placeCaret(element, offset);
await userEvent.keyboard('x');
await settle();
await userEvent.keyboard('=');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
expect(caretLineColumn(element)).toMatchObject({
line: 1,
column: ' doStuff()x'.length
});
});
it('keeps the caret in a collapsed gutter through an async re-highlight', async () => {
const initial = 'function foo() {\n doStuff()\n}\n';
const {
handle,
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minColumn: 2,
minRow: 1,
maxRow: 1,
onBoundary: vi.fn(),
preParse: asyncPreParse()
});
const offset = 'function foo() {\n doStuff()'.length;
await placeCaret(element, offset);
await userEvent.keyboard('x');
await settle();
await settle();
await userEvent.keyboard('=');
await settle();
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
await settle();
expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
expect(caretLineColumn(element)).toMatchObject({
line: 1,
column: ' doStuff()x'.length
});
});
it('lands the caret at the line/gap boundary when editing at the end of a line', async () => {
// The real bug uses the `End` key to land the caret at the line end — which
// in the framed `.line` structure is the boundary with the inter-line gap
// node. Native typing there can flatten the spans / split across lines.
const initial = 'function foo() {\n doStuff()\n}\n';
const {
handle,
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line'
});
// Put the caret somewhere on line 1, then End to the line end.
await placeCaret(element, 'function foo() {\n do'.length);
await userEvent.keyboard('{End}');
await settle();
await userEvent.keyboard('x');
await settle();
await userEvent.keyboard('=');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('function foo() {\n doStuff()x\n}\n');
expect(caretLineColumn(element)).toMatchObject({
line: 1,
column: ' doStuff()x'.length
});
});
// -------------------------------------------------------------------------
// Caret restoration when erasing the last indent on a clipped (collapsed-window) line
// -------------------------------------------------------------------------
it('restores the caret when backspacing the last indent of a blank clipped line (minColumn)', async () => {
// Simulate a collapsed window: indentation clipped to minColumn=2, the
// visible region is rows 1..3. Line 2 is a blank line with exactly 2 spaces.
const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minColumn: 2,
minRow: 1,
maxRow: 3,
onBoundary: vi.fn()
}, {
scroll: true
});
// caret at end of the blank line 2 (column 2 == minColumn)
const offset = 'function foo() {\n const a = 1;\n '.length;
await placeCaret(element, offset);
const before = caretLineColumn(element);
await userEvent.keyboard('{Backspace}');
await settle();
const after = caretLineColumn(element);
// Assert the (arguably correct) behavior: stay on the same line, now empty.
expect(after.line).toBe(before.line);
});
it('restores the caret when backspacing an indent on a content line in a clipped gutter (minColumn)', async () => {
const initial = 'function foo() {\n const a = 1;\n}\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minColumn: 2,
minRow: 1,
maxRow: 1,
onBoundary: vi.fn()
}, {
scroll: true
});
// caret right after the 2-space indent on line 1 (` const a = 1;`)
const offset = 'function foo() {\n '.length;
await placeCaret(element, offset);
await userEvent.keyboard('{Backspace}');
await settle();
const after = caretLineColumn(element);
expect(after).toBeTruthy();
});
// -------------------------------------------------------------------------
// ArrowUp at the visible top fires onBoundary (scroll anchor)
// -------------------------------------------------------------------------
it('fires onBoundary on ArrowUp at the first row and ArrowDown at the last row', async () => {
const onBoundary = vi.fn();
const initial = 'line0\nline1\nline2\nline3\nline4\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minRow: 2,
maxRow: 3,
onBoundary
}, {
scroll: true
});
// Caret on line 2 (minRow), then ArrowUp.
const upOffset = 'line0\nline1\n'.length + 2;
await placeCaret(element, upOffset);
await userEvent.keyboard('{ArrowUp}');
await settle();
const upCalls = onBoundary.mock.calls.length;
// Caret on line 3 (maxRow), then ArrowDown.
const downOffset = 'line0\nline1\nline2\n'.length + 2;
await placeCaret(element, downOffset);
await userEvent.keyboard('{ArrowDown}');
await settle();
const downCalls = onBoundary.mock.calls.length - upCalls;
expect(upCalls).toBeGreaterThan(0); // ArrowUp must fire the boundary
expect(downCalls).toBeGreaterThan(0); // ArrowDown must fire the boundary
});
// -------------------------------------------------------------------------
// Transient DOM vs committed source during an async re-highlight: backspacing the
// last indent collapses the line during the worker round-trip, before the committed
// source catches up.
// -------------------------------------------------------------------------
it('keeps the transient DOM consistent with the committed source when async-backspacing the last indent', async () => {
const {
preParse,
resolvePending
} = deferredPreParse();
const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minColumn: 2,
minRow: 1,
maxRow: 3,
onBoundary: vi.fn(),
preParse
}, {
scroll: true
});
const offset = 'function foo() {\n const a = 1;\n '.length;
await placeCaret(element, offset);
// Compact structural signature: the frame's direct children, each as
// either `LINE(<text>)` or `TEXT(<text>)`, so we can see the mangled
// transient structure without vitest truncating a long innerHTML string.
const signature = () => {
const frame = element.querySelector('.frame');
return Array.from(frame.childNodes).map(node => {
if (node.nodeType === Node.TEXT_NODE) {
return `TEXT(${JSON.stringify(node.textContent)})`;
}
const childEl = node;
if (childEl.classList?.contains('line')) {
return `LINE(${JSON.stringify(childEl.textContent)})`;
}
return `OTHER(${childEl.tagName})`;
});
};
await userEvent.keyboard('{Backspace}');
await settle();
// DURING the async gap (preParse not yet resolved): what does the DOM show?
const transientSig = signature();
await resolvePending();
await settle();
const finalSig = signature();
// The transient DOM the user sees during the async worker round-trip must already
// be structurally consistent with the final committed result — no wrong intermediate
// state (no dangling empty `.line` span, no dropped gap newline), so the signatures match.
expect(transientSig).toEqual(finalSig);
});
// -------------------------------------------------------------------------
// ArrowUp navigation across consecutive empty lines
// -------------------------------------------------------------------------
it('moves ArrowUp onto an empty line first, then the line above', async () => {
const initial = 'aaa\n\nbbb\nccc\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line'
});
// Start on "ccc" (line 3), column 1.
const start = 'aaa\n\nbbb\n'.length + 1;
await placeCaret(element, start);
await userEvent.keyboard('{ArrowUp}');
await settle();
const step1 = caretLineColumn(element); // expect line 2 ("bbb")
await userEvent.keyboard('{ArrowUp}');
await settle();
const step2 = caretLineColumn(element); // expect line 1 (empty)
await userEvent.keyboard('{ArrowUp}');
await settle();
const step3 = caretLineColumn(element); // expect line 0 ("aaa")
// At the engine level the empty line IS reachable and each step lands on
// the expected row (this passes in all 3 browsers). If the "weird" ArrowUp
// behavior shows up in the real docs, it is not in this engine path —
// suspect the real frame/visibleFrames render or layout, not navigation.
expect(step1.line).toBe(2);
expect(step2.line).toBe(1); // the empty line — reachable, not skipped
expect(step3.line).toBe(0);
});
// -------------------------------------------------------------------------
// New bug: TWO consecutive empty lines — ArrowUp skips both at once.
// -------------------------------------------------------------------------
it('stops ArrowUp on each of two consecutive empty lines (does not skip both)', async () => {
// Lines: 0 "aaa", 1 "" , 2 "", 3 "bbb", 4 "ccc".
const initial = 'aaa\n\n\nbbb\nccc\n';
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line'
});
// Start on "bbb" (line 3), column 1.
const start = 'aaa\n\n\n'.length + 1;
await placeCaret(element, start);
await userEvent.keyboard('{ArrowUp}');
await settle();
const step1 = caretLineColumn(element); // expect line 2 (second empty line)
await userEvent.keyboard('{ArrowUp}');
await settle();
const step2 = caretLineColumn(element); // expect line 1 (first empty line)
await userEvent.keyboard('{ArrowUp}');
await settle();
const step3 = caretLineColumn(element); // expect line 0 ("aaa")
// DESIRED: ArrowUp stops on EACH line, including the zero-height empty ones.
// The bug skips both empty lines, so a single ArrowUp from "bbb" jumps
// straight to "aaa" (step1.line === 0).
const ctx = JSON.stringify({
step1,
step2,
step3
});
expect(step1.line, ctx).toBe(2); // second empty line
expect(step2.line, ctx).toBe(1); // first empty line
expect(step3.line, ctx).toBe(0); // "aaa"
});
// -------------------------------------------------------------------------
// A BACKWARD Shift+Arrow selection keeps its focus at the top across a
// host re-render (the restore must not flip the focus to the bottom end).
// -------------------------------------------------------------------------
it('preserves a backward Shift+ArrowUp selection across a re-render, focus still at the top', async () => {
const initial = 'aaa\nbbb\nccc\nddd\n';
const {
handle,
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line'
});
// Reads the moving end (focus) and the fixed end (anchor) of the live
// selection as (line, column), so we can assert the selection DIRECTION —
// `caretLineColumn` only reports the forward-normalized range start.
const endPoint = (node, offset) => {
const until = document.createRange();
until.setStart(element, 0);
until.setEnd(node, offset);
const lines = until.toString().split('\n');
return {
line: lines.length - 1,
column: lines[lines.length - 1].length
};
};
const focusPoint = () => {
const sel = window.getSelection();
return endPoint(sel.focusNode, sel.focusOffset);
};
const anchorPoint = () => {
const sel = window.getSelection();
return endPoint(sel.anchorNode, sel.anchorOffset);
};
// Start collapsed on "ddd" (line 3), column 0, then grow the selection
// UPWARD twice so the focus is above the anchor (a backward range).
await placeCaret(element, 'aaa\nbbb\nccc\n'.length);
await userEvent.keyboard('{Shift>}{ArrowUp}{ArrowUp}{/Shift}');
await settle();
// Anchor stays on line 3; the focus has climbed two lines up to line 1.
expect(anchorPoint().line, 'anchor before re-render').toBe(3);
expect(focusPoint().line, 'focus before re-render').toBe(1);
// An idle host re-render (e.g. an async re-highlight committing) re-runs the
// engine's caret/selection restore. The backward direction must survive it.
act(() => {
handle.rerender();
});
await settle();
expect(anchorPoint().line, 'anchor after re-render').toBe(3);
// The bug: the restore rebuilt a forward range, flipping the focus to the
// bottom (line 3). With the fix the focus stays at the top (line 1).
expect(focusPoint().line, 'focus after re-render').toBe(1);
// And the next Shift+ArrowUp must keep extending from the TOP — landing the
// focus on line 0 — rather than collapsing the selection from the bottom.
await userEvent.keyboard('{Shift>}{ArrowUp}{/Shift}');
await settle();
expect(focusPoint().line, 'focus after a third Shift+ArrowUp').toBe(0);
expect(anchorPoint().line, 'anchor unchanged after third Shift+ArrowUp').toBe(3);
});
// -------------------------------------------------------------------------
// Focus retention on the first keystroke (async preParse path)
// -------------------------------------------------------------------------
it('keeps focus after the first keystroke through an async re-highlight', async () => {
const {
element
} = await setupEditor('hello\n', {
indentation: 2,
caretSelector: '.line',
preParse: asyncPreParse()
});
await placeCaret(element, 5);
expect(document.activeElement).toBe(element);
await userEvent.keyboard('!');
await settle();
await settle();
expect(document.activeElement).toBe(element);
});
// -------------------------------------------------------------------------
// Single-bound paging: PageDown engages only when `maxRow` is set and PageUp
// only when `minRow` is set — mirroring ArrowDown (needs `maxRow`) / ArrowUp
// (needs `minRow`). With only the OPPOSITE bound present the page key has no
// fold in its direction, so it falls through to native and must NOT fire
// `onBoundary`. Latent through `Pre` (which always supplies both bounds), but
// `Options.minRow`/`maxRow` are independently optional.
// -------------------------------------------------------------------------
it('pages to the fold only for the bound in its own direction', async () => {
const initial = 'line0\nline1\nline2\nline3\nline4\n';
// Only `maxRow` (a bottom fold, no top fold): PageDown expands, PageUp is native.
{
const onBoundary = vi.fn();
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
maxRow: 2,
onBoundary
}, {
scroll: true
});
await placeCaret(element, 'line0\n'.length); // line 1, inside the window
await userEvent.keyboard('{PageUp}');
await settle();
expect(onBoundary, 'PageUp with no minRow stays native').not.toHaveBeenCalled();
await userEvent.keyboard('{PageDown}');
await settle();
expect(onBoundary, 'PageDown expands the bottom fold').toHaveBeenCalledTimes(1);
expect(caretLineColumn(element).line, 'caret jumped to the bottom edge').toBe(2);
}
// Only `minRow` (a top fold, no bottom fold): PageUp expands, PageDown is native.
{
const onBoundary = vi.fn();
const {
element
} = await setupEditor(initial, {
indentation: 2,
caretSelector: '.line',
minRow: 2,
onBoundary
}, {
scroll: true
});
await placeCaret(element, 'line0\nline1\nline2\nline3\n'.length); // line 4, inside the window
await userEvent.keyboard('{PageDown}');
await settle();
expect(onBoundary, 'PageDown with no maxRow stays native').not.toHaveBeenCalled();
await userEvent.keyboard('{PageUp}');
await settle();
expect(onBoundary, 'PageUp expands the top fold').toHaveBeenCalledTimes(1);
expect(caretLineColumn(element).line, 'caret jumped to the top edge').toBe(2);
}
});
});
// ---------------------------------------------------------------------------
// deletedFromLineStart: whole-line removals only, not in-place collapses
// ---------------------------------------------------------------------------
// A selection delete reports `deletedFromLineStart` so the controlled
// comment/highlight map drops its anchor one line — the post-delete caret sits on
// a line that shifted up from below the deletion. That must be limited to
// deletions that removed WHOLE lines. A selection that ends mid-line collapses the
// spanned lines INTO the first line, which survives (emptied) under the caret;
// reporting the flag there drags a marker on that surviving line one line too high
// (the live-editor "the highlight shifts up instead of being deleted" bug). The
// source stands in for an @highlight block on lines 4-6 with blank padding (3, 7).
describe('useEditable — selection delete reports deletedFromLineStart only for whole-line removals', () => {
const SRC = 'const a = 1;\nconst b = 2;\n\ndoThing();\ndoOther();\ndoLast();\n\nconst c = 3;\n';
const lastPosition = handle => handle.onChange.mock.calls.at(-1)?.[1];
it('reports the flag when a column-0 selection removes whole lines', async () => {
const {
handle,
element
} = await setupEditor(SRC, {
indentation: 2,
caretSelector: '.line'
});
// Column 0 of the blank line 3 → column 0 of the blank line 7 (a line boundary):
// whole lines 3-6 are removed and line 7 shifts up under the caret.
await placeCaret(element, 26);
await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{/Shift}');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\n\nconst c = 3;\n');
expect(lastPosition(handle)?.deletedFromLineStart).toBe(true);
});
it('reports the flag when a column-0 selection stops on the region’s exclusive -end line', async () => {
const {
handle,
element
} = await setupEditor(SRC, {
indentation: 2,
caretSelector: '.line'
});
// Column 0 of the blank line 3 → column 0 of line 6 (still a line boundary), one
// ArrowDown short of scenario A. A range's @highlight-end is EXCLUSIVE — it sits
// on the line just below the last highlighted line — so selecting "from the line
// above through the last newline of the region" lands here, deleting the whole
// highlighted body (lines 4-5) while doLast() (the -end line) survives. This is
// still a whole-line removal, so the flag holds; the matching comment-map case is
// `useSourceEditing`'s "removes the highlight when the selection stops on the
// region’s exclusive -end line".
await placeCaret(element, 26);
await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{ArrowDown}{/Shift}');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\ndoLast();\n\nconst c = 3;\n');
expect(lastPosition(handle)?.deletedFromLineStart).toBe(true);
});
it('does NOT report the flag when the selection ends mid-line and collapses in place', async () => {
const {
handle,
element
} = await setupEditor(SRC, {
indentation: 2,
caretSelector: '.line'
});
// Column 0 of line 4 → the END of line 6 (mid-line, NOT a line boundary): the three
// region lines collapse into one empty line that survives under the caret.
await placeCaret(element, 27);
await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{End}{/Shift}');
await settle();
await userEvent.keyboard('{Backspace}');
await settle();
expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\n\n\n\nconst c = 3;\n');
expect(lastPosition(handle)?.deletedFromLineStart).not.toBe(true);
});
});