@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
402 lines (390 loc) • 14.5 kB
JavaScript
/*
* Pure DOM/text helpers extracted from useEditable.ts. None of these
* touch React state or the hook's internal `state` object — they only
* read from / mutate the DOM and the browser Selection. Kept in a
* sibling file (per AGENTS.md docs-infra rule 2.3) so the main hook
* stays focused on lifecycle wiring and event handling.
*/
export const getCurrentRange = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// Internal helper — only called from event handlers and edit methods
// that have already verified there is an active selection. Throwing
// here surfaces contract violations early instead of letting them
// explode further down the call stack (matching the prior implicit
// `DOMException` from `getRangeAt(0)` on an empty selection).
throw new Error('useEditable: expected an active selection');
}
return selection.getRangeAt(0);
};
export const setCurrentRange = range => {
const selection = window.getSelection();
if (!selection) {
return;
}
selection.empty();
selection.addRange(range);
};
/**
* Narrow a `Node | null` to `Element | null` using a runtime check so
* downstream code can reason about element-only APIs without a cast.
*/
export const asElement = node => node instanceof Element ? node : null;
/**
* Pull the next element out of a `SHOW_ELEMENT` `TreeWalker` with a
* runtime check rather than a type cast. Tree walkers configured for
* `SHOW_ELEMENT` only emit elements in practice, but the DOM type
* exposes `Node | null`.
*/
export const nextElement = walker => asElement(walker.nextNode());
export const isUndoRedoKey = event => (event.metaKey || event.ctrlKey) && !event.altKey && event.code === 'KeyZ';
export const isPlaintextInputKey = event => {
const usesAltGraph = typeof event.getModifierState === 'function' && event.getModifierState('AltGraph');
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && (!event.altKey || usesAltGraph);
};
export const toString = element => {
const content = element.textContent || '';
// contenteditable Quirk: Without plaintext-only a pre/pre-wrap element must always
// end with at least one newline character
if (content[content.length - 1] !== '\n') {
return `${content}\n`;
}
return content;
};
/**
* Walk text nodes to extract the requested line plus its immediate
* neighbors without materializing the full document text or splitting
* it into a per-line array. Used by per-keystroke handlers (arrow keys,
* Backspace, gutter snapping) so they stay O(chars-on-touched-lines)
* instead of O(document-length) on every event.
*
* Walks each text node in document order and slices contiguous segments
* directly into the relevant accumulator (`prevLine` / `currentLine` /
* `nextLine`). Skips chunks belonging to lines we don't care about and
* exits as soon as the trailing `\n` of `lineIndex + 1` is consumed.
*
* Mirrors `toString(element).split('\n').slice(0, -1)` semantics:
*
* - `hasNextLine` is `true` whenever a real line follows `currentLine`,
* even if that line is blank — `"a\n\nb\n"` reports a next line for
* row 0. The phantom empty entry that `split` produces after the
* document's trailing `\n` is intentionally ignored.
* - The implicit trailing newline that `toString` appends when the DOM
* doesn't end with one has no effect: we walk raw text content.
*/
export const getLineInfo = (element, lineIndex) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let currentLine = '';
let prevLine = '';
let nextLine = '';
let hasNextLine = false;
let line = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent ?? '';
let segStart = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] !== '\n') {
continue;
}
// Flush the segment that lives on `line` before crossing the newline.
if (segStart < i) {
const segment = text.slice(segStart, i);
if (line === lineIndex - 1) {
prevLine += segment;
} else if (line === lineIndex) {
currentLine += segment;
} else if (line === lineIndex + 1) {
nextLine += segment;
}
}
// We're about to cross the `\n` that terminates `line`. If `line`
// is the next line, we've now fully read it and confirmed it
// exists (a terminator means there is at least one more position
// in the document past `currentLine`'s end).
if (line === lineIndex + 1) {
hasNextLine = true;
return {
currentLine,
prevLine,
nextLine,
hasNextLine
};
}
line += 1;
segStart = i + 1;
}
// Tail segment of this text node belongs to `line` (no newline yet).
if (segStart < text.length) {
const segment = text.slice(segStart);
if (line === lineIndex - 1) {
prevLine += segment;
} else if (line === lineIndex) {
currentLine += segment;
} else if (line === lineIndex + 1) {
// An unterminated tail on `lineIndex + 1` is the document's
// last (real) line — it counts as a next line. The phantom
// empty entry produced by `toString`'s trailing `\n` has no
// tail, so it correctly leaves `hasNextLine` false.
nextLine += segment;
hasNextLine = true;
}
}
}
return {
currentLine,
prevLine,
nextLine,
hasNextLine
};
};
/**
* Convert a `(row, column)` coordinate into an absolute character offset
* by counting newlines through the editable's text nodes, exiting the
* moment we land on the requested row. Avoids the
* `toString(element).split('\n').slice(0, row).join('\n').length`
* round-trip — that pattern allocates the full document string and a
* full per-line array on every `edit.move({row, column})` call.
*
* If the row is past the end of the document, returns the document
* length plus `column` so the eventual `makeRange` clamps gracefully.
*/
export const getOffsetAtLineColumn = (element, row, column) => {
if (row <= 0) {
return Math.max(0, column);
}
let offset = 0;
let line = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent ?? '';
for (let i = 0; i < text.length; i += 1) {
offset += 1;
if (text[i] === '\n') {
line += 1;
if (line === row) {
return offset + column;
}
}
}
}
return offset + column;
};
export const repairUnexpectedLineMerge = (newContent, previousContent, position) => {
if (previousContent == null || position.extent !== 0) {
return newContent;
}
const previousLines = previousContent.split('\n');
const nextLines = newContent.split('\n');
if (nextLines.length >= previousLines.length) {
return newContent;
}
const cursorLine = position.line;
for (let i = 0; i < cursorLine && i < nextLines.length; i += 1) {
if (nextLines[i] !== previousLines[i]) {
return newContent;
}
}
const linesLost = previousLines.length - nextLines.length;
const mergedPreviousContent = previousLines.slice(cursorLine + 1, cursorLine + 1 + linesLost).join('');
if (!nextLines[cursorLine]?.endsWith(mergedPreviousContent)) {
return newContent;
}
const editedCursorLine = nextLines[cursorLine].slice(0, nextLines[cursorLine].length - mergedPreviousContent.length);
if (editedCursorLine === previousLines[cursorLine]) {
return newContent;
}
return [...nextLines.slice(0, cursorLine), editedCursorLine, ...previousLines.slice(cursorLine + 1, cursorLine + 1 + linesLost), ...nextLines.slice(cursorLine + 1)].join('\n');
};
const setStart = (range, node, offset) => {
const length = (node.textContent ?? '').length;
if (offset < length) {
range.setStart(node, offset);
} else {
range.setStartAfter(node);
}
};
const setEnd = (range, node, offset) => {
const length = (node.textContent ?? '').length;
if (offset < length) {
range.setEnd(node, offset);
} else {
range.setEndAfter(node);
}
};
export const getPosition = element => {
const range = getCurrentRange();
const extent = !range.collapsed ? range.toString().length : 0;
// Fast path: cursor is in a text node (Chrome/Safari with plaintext-only, and
// Firefox after edit.insert repositions the cursor). Walk text nodes to count
// characters without allocating an O(cursor-position) string.
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let position = 0;
let line = 0;
let lineContent = '';
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent ?? '';
const isTarget = node === range.startContainer;
const upTo = isTarget ? range.startOffset : text.length;
let segStart = 0;
for (let i = 0; i < upTo; i += 1) {
if (text[i] === '\n') {
line += 1;
lineContent = '';
segStart = i + 1;
}
}
lineContent += text.slice(segStart, upTo);
position += upTo;
if (isTarget) {
break;
}
}
return {
position,
extent,
content: lineContent,
line
};
}
// Firefox fallback: cursor may be at an element boundary (e.g. after a click
// before any edit). Use Range.toString() to extract the pre-cursor text.
// Firefox Quirk: Since plaintext-only is unsupported, the selection can land
// on element nodes rather than text nodes.
const untilRange = document.createRange();
untilRange.setStart(element, 0);
untilRange.setEnd(range.startContainer, range.startOffset);
let content = untilRange.toString();
const position = content.length;
const lines = content.split('\n');
const line = lines.length - 1;
content = lines[line];
return {
position,
extent,
content,
line
};
};
export const makeRange = (element, start, end) => {
if (start <= 0) {
start = 0;
}
if (!end || end < 0) {
end = start;
}
const range = document.createRange();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let current = 0;
let position = start;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const length = (node.textContent ?? '').length;
if (current + length >= position) {
const offset = position - current;
if (position === start) {
setStart(range, node, offset);
if (end === start) {
break;
}
position = end;
if (current + length >= position) {
setEnd(range, node, position - current);
break;
}
// end is in a later node — fall through to advance current
} else {
setEnd(range, node, offset);
break;
}
}
current += length;
}
return range;
};
/** Walk to the next text node in document order without allocating a TreeWalker. */
const nextTextNode = node => {
let current = node;
// Walk up and across siblings until we find a branch to descend into.
while (current) {
if (current.nextSibling) {
current = current.nextSibling;
// Descend to the first text node.
while (current.firstChild) {
current = current.firstChild;
}
if (current.nodeType === Node.TEXT_NODE) {
return current;
}
// Not a text leaf — continue walking siblings from here.
continue;
}
current = current.parentNode;
}
return null;
};
/**
* After makeRange positions a collapsed cursor at a newline boundary via
* setStartAfter(textNode), the cursor ends up inside the *previous* line span
* (after the '\n'). This adjusts the range forward to offset 0 of the
* next text node so the cursor renders on the correct visual line.
*/
export const adjustCursorAtNewlineBoundary = range => {
if (!range.collapsed) {
return;
}
const {
startContainer,
startOffset
} = range;
const startText = startContainer.textContent ?? '';
// Case 1: cursor is in a text node at the very end and that text ends with '\n'
if (startContainer.nodeType === Node.TEXT_NODE && startOffset === startText.length && startText.endsWith('\n')) {
const next = nextTextNode(startContainer);
if (next) {
range.setStart(next, 0);
range.collapse(true);
}
return;
}
// Case 2: cursor is at an element boundary where the previous child is a
// text node ending with '\n' (happens when setStartAfter places us here)
if (startContainer.nodeType === Node.ELEMENT_NODE && startOffset > 0) {
const prevChild = startContainer.childNodes[startOffset - 1];
const prevText = prevChild?.textContent ?? '';
if (prevChild?.nodeType === Node.TEXT_NODE && prevText.endsWith('\n')) {
const next = nextTextNode(prevChild);
if (next) {
range.setStart(next, 0);
range.collapse(true);
}
}
}
};
/**
* Rebuild the browser selection from a tracked {@link Position} after a host
* re-render. Recreates the `[position, position + extent]` range (collapsed when
* `extent` is 0) and applies the newline-boundary nudge.
*
* When `position.backward` is set, the range is restored as a BACKWARD selection
* — anchor at the range end, focus at the range start — by collapsing to the end
* and extending back to the start. A range added via `Selection.addRange` is
* always forward, so without this a backward Shift+Arrow selection would have its
* focus flipped to the bottom end on every restore. Forward and collapsed
* positions take the plain `addRange` path unchanged.
*/
export const restoreSelection = (element, position) => {
const range = makeRange(element, position.position, position.position + position.extent);
adjustCursorAtNewlineBoundary(range);
if (position.backward && position.extent > 0) {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
// Anchor at the bottom (range end), then move the focus up to the range
// start so the selection direction matches the user's Shift+Arrow.
selection.collapse(range.endContainer, range.endOffset);
selection.extend(range.startContainer, range.startOffset);
return;
}
}
setCurrentRange(range);
};