', async () => {
const { container } = render();
const pre = container.querySelector('pre')!;
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index e4290384c..8140b65aa 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -33,7 +33,7 @@ SOFTWARE.
// - Replace manual queue-based DFS in makeRange with TreeWalker for better performance
// - Replace Range.toString() in getPosition with a TreeWalker character count to avoid O(N) string allocation
// - Deduplicate toString() calls via trackState return value
-// - Fix Firefox rapid-typing line-loss bug: preserve pre-edit pendingContent across key-repeat keydowns
+// - Fix Firefox rapid-typing line-loss bug: preserve pre-edit pendingContent across keydowns until flush
// - Debounce repeat-key flushes so highlights only re-render once the user pauses typing
// - Fix undo-to-initial-state bug: allow trackState to record before the first flushChanges
// - Fix undo-after-rapid-Enter bug: bypass 500ms dedup on keyup for structural edits (Enter)
@@ -623,12 +623,15 @@ export const useEditable = (
return;
}
- // Only capture the pre-edit snapshot on the first keydown in a key-repeat
- // sequence. Repeated keydowns must NOT overwrite pendingContent because the DOM
- // may already contain a Firefox-merged state after the first keystroke. If we
- // overwrote pendingContent here, repairUnexpectedLineMerge would receive the
- // merged DOM as the "previous" content and could not detect that a line was lost.
- if (!event.repeat || state.pendingContent === null) {
+ // Only capture the pre-edit snapshot when no edit is currently pending
+ // (i.e. the previous keystroke has already been flushed on keyup).
+ // Overwriting pendingContent on a rapid second keydown — whether the
+ // same key repeating OR a different key pressed before the first
+ // keyup — would lose the baseline that repairUnexpectedLineMerge
+ // needs to detect Firefox's line-merge quirk. The DOM may already
+ // contain a merged state when the second keydown fires; treating that
+ // as "previous" content makes the line-loss invisible.
+ if (state.pendingContent === null) {
state.pendingContent = trackState() ?? toString(element);
}
From 917dad853e2ebc8ae9b2ac06620e5751ab61fb94 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Wed, 22 Apr 2026 16:57:17 -0400
Subject: [PATCH 26/45] Fix highlight state bug
---
.../src/useCode/useSourceEditing.test.ts | 66 +++++++++++++++++++
.../src/useCode/useSourceEditing.ts | 15 ++++-
2 files changed, 78 insertions(+), 3 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.test.ts b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
index e1288c6b5..8e4f789d4 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.test.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
@@ -22,6 +22,10 @@ function pos(line: number): Position {
return { position: 0, extent: 0, content: '', line };
}
+function posWithExtent(line: number, extent: number): Position {
+ return { position: 0, extent, content: '', line };
+}
+
/**
* Captures the ControlledCode produced by setSource by intercepting the
* setState updater function passed to context.setCode.
@@ -423,6 +427,68 @@ describe('useSourceEditing', () => {
expect(undone!.Default!.collapseMap).toBeUndefined();
});
+ it('keeps highlight in place when undoing a multi-line selection delete', () => {
+ // Simulates: user has highlight on lines 7-8, types text inside the
+ // highlighted region (adding 2 lines AFTER line 8), selects the typed
+ // text (extent > 0), backspaces to delete it, then presses Ctrl+Z.
+ //
+ // The undo replays the saved pre-deletion state, where `position`
+ // points to the SELECTION-START (not the post-edit cursor). Without
+ // accounting for `extent > 0`, shiftComments mistakenly thinks the
+ // edit happened earlier in the file and shifts the highlighted lines
+ // downward — so the user sees the highlight on the typed lines
+ // instead of on its original location.
+ const comments: SourceComments = {
+ 7: ['@highlight-start'],
+ 8: ['@highlight-end'],
+ };
+ const originalSource = 'L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11';
+ // Typed text adds 2 lines after L8: 'test', '
\ntest\nL9\nL10\nL11';
+
+ const selectedVariant: VariantCode = {
+ fileName: 'App.tsx',
+ source: originalSource,
+ comments,
+ };
+ const effectiveCode: Code = { Default: selectedVariant };
+ const context = createContext();
+
+ const { result } = renderHook(() =>
+ useSourceEditing({
+ context,
+ selectedVariantKey: 'Default',
+ effectiveCode,
+ selectedVariant,
+ }),
+ );
+
+ // Step 1: type the text (single setSource for simplicity).
+ // Cursor lands at end of last "test" → 0-indexed line 9.
+ act(() => result.current.setSource!(typedSource, undefined, pos(9)));
+ const afterType = captureControlledCode(context);
+ // Highlight stays on lines 7 (L7) and 8 (L8test).
+ expect(afterType!.Default!.comments).toEqual(comments);
+
+ // Step 2: select the typed text and Backspace.
+ // Cursor lands at start of selection (end of L8) → 0-indexed line 7.
+ act(() => result.current.setSource!(originalSource, undefined, pos(7)));
+ const afterDelete = captureControlledCode(context, afterType);
+ expect(afterDelete!.Default!.comments).toEqual(comments);
+
+ // Step 3: Ctrl+Z restores the pre-Backspace state. The saved position
+ // is the SELECTION-START in the typed text — line 7 (0-indexed) with
+ // extent = 25 (length of selected text).
+ act(() => result.current.setSource!(typedSource, undefined, posWithExtent(7, 25)));
+ const afterUndo = captureControlledCode(context, afterDelete);
+
+ // Highlight must remain on lines 7 (L7) and 8 (L8test) — the same
+ // logical lines as before the delete. Without the extent-aware fix,
+ // they would incorrectly shift to lines 9 and 10 (the typed content).
+ expect(afterUndo!.Default!.comments).toEqual(comments);
+ });
+
it('places -end comments at editLine+1 instead of editLine when collapsing', () => {
// Simulates deleting whitespace before
in JSX, merging two lines.
// @highlight-end should stay at the line AFTER the merged content, not
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.ts b/packages/docs-infra/src/useCode/useSourceEditing.ts
index 9b705334c..7c77390e8 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.ts
@@ -71,9 +71,18 @@ function shiftComments(
// position.line is 0-indexed in the new text.
// lineDelta is positive for insertions and negative for deletions.
// Convert to the 1-indexed line in old text that the cursor was on:
- // For additions (lineDelta > 0): cursor moved down, old line = position.line - lineDelta
- // For deletions (lineDelta < 0): cursor stayed, old line = position.line
- const editLine = position.line - Math.max(0, lineDelta) + 1; // 1-indexed
+ // For additions (lineDelta > 0):
+ // - Forward typing: position is the POST-edit cursor (extent === 0).
+ // Cursor moved down by lineDelta, so old line = position.line - lineDelta.
+ // - Undo of a multi-line delete: the saved position has extent > 0 and
+ // points to the SELECTION-START in the redone text — i.e. where the
+ // re-inserted lines begin. The "edit line" is that line itself; the
+ // new lines come AFTER it.
+ // For deletions (lineDelta < 0): cursor stayed where it was, old line = position.line.
+ const isUndoOfMultiLineDelete = lineDelta > 0 && position.extent > 0;
+ const editLine = isUndoOfMultiLineDelete
+ ? position.line + 1
+ : position.line - Math.max(0, lineDelta) + 1; // 1-indexed
const shifted: SourceComments = {};
let collapseMap: CollapseMap = existingCollapseMap ? { ...existingCollapseMap } : {};
From c3f2f9feabc11d20d674e6e459f944df3edd0aeb Mon Sep 17 00:00:00 2001
From: dav-is
Date: Wed, 22 Apr 2026 17:13:12 -0400
Subject: [PATCH 27/45] Handle removing empty lines at the start of a region
---
.../components/code-highlighter/types.md | 2 +
.../docs-infra/src/CodeHighlighter/types.ts | 2 +
.../src/useCode/useSourceEditing.test.ts | 51 +++++++++++
.../src/useCode/useSourceEditing.ts | 84 +++++++++++++++----
4 files changed, 121 insertions(+), 18 deletions(-)
diff --git a/docs/app/docs-infra/components/code-highlighter/types.md b/docs/app/docs-infra/components/code-highlighter/types.md
index f98608e0e..b686843c5 100644
--- a/docs/app/docs-infra/components/code-highlighter/types.md
+++ b/docs/app/docs-infra/components/code-highlighter/types.md
@@ -595,6 +595,7 @@ type ControlledVariantCode = {
comments?: SourceComments;
collapseMap?: CollapseMap;
totalLines?: number;
+ emptyLines?: number[];
};
```
@@ -607,6 +608,7 @@ type ControlledVariantExtraFiles = {
comments?: SourceComments;
collapseMap?: CollapseMap;
totalLines?: number;
+ emptyLines?: number[];
};
};
```
diff --git a/packages/docs-infra/src/CodeHighlighter/types.ts b/packages/docs-infra/src/CodeHighlighter/types.ts
index 1c160c9ed..f1006b253 100644
--- a/packages/docs-infra/src/CodeHighlighter/types.ts
+++ b/packages/docs-infra/src/CodeHighlighter/types.ts
@@ -98,6 +98,7 @@ export type ControlledVariantExtraFiles = {
comments?: SourceComments;
collapseMap?: CollapseMap;
totalLines?: number;
+ emptyLines?: number[];
};
};
export type ControlledVariantCode = CodeMeta & {
@@ -108,6 +109,7 @@ export type ControlledVariantCode = CodeMeta & {
comments?: SourceComments;
collapseMap?: CollapseMap;
totalLines?: number;
+ emptyLines?: number[];
};
export type ControlledCode = { [key: string]: undefined | null | ControlledVariantCode };
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.test.ts b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
index 8e4f789d4..888672d91 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.test.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.test.ts
@@ -489,6 +489,57 @@ describe('useSourceEditing', () => {
expect(afterUndo!.Default!.comments).toEqual(comments);
});
+ it('reduces (does not shift) the highlight when deleting an empty line at the start', () => {
+ // Highlighted region: lines 7-9 where L7 is an empty/whitespace-only line.
+ // User backspaces at the start of L7, merging it into L6.
+ // Old: L6='
', L7=' ', L8=' ' (@hl-end)
+ //
+ // Since the deleted L7 had no real content that shifted into L6, the
+ // user expects the highlight to "lose" that empty line and start on
+ // the next line (now L7 = ) — NOT shift the start marker
+ // up onto the
line).
+ // Instead it should land on what is now L7 (the line),
+ // shrinking the highlighted range from 3 lines to 2.
+ expect(variant.comments![6]).toBeUndefined();
+ expect(variant.comments![7]).toEqual(['@highlight-start']);
+ // @highlight-end shifts from L9 to L8 (one line removed before it).
+ expect(variant.comments![8]).toEqual(['@highlight-end']);
+ });
+
it('places -end comments at editLine+1 instead of editLine when collapsing', () => {
// Simulates deleting whitespace before
in JSX, merging two lines.
// @highlight-end should stay at the line AFTER the merged content, not
diff --git a/packages/docs-infra/src/useCode/useSourceEditing.ts b/packages/docs-infra/src/useCode/useSourceEditing.ts
index 7c77390e8..c05a806d1 100644
--- a/packages/docs-infra/src/useCode/useSourceEditing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEditing.ts
@@ -31,17 +31,40 @@ interface ShiftResult {
}
/**
- * Counts the number of lines in a string without allocating an intermediate array.
+ * Counts the number of lines in a string and records which 1-indexed lines are
+ * empty/whitespace-only, in a single pass, without allocating a line array.
+ * `emptyLines` is omitted when no blank lines were found to keep the common
+ * case allocation-free.
*/
-function countSourceLines(source: string): number {
- let count = 1;
- let idx = 0;
- // eslint-disable-next-line no-cond-assign
- while ((idx = source.indexOf('\n', idx)) !== -1) {
- count += 1;
- idx += 1;
+function analyzeSource(source: string): { totalLines: number; emptyLines?: number[] } {
+ let totalLines = 1;
+ let emptyLines: number[] | undefined;
+ let lineStart = 0;
+ const len = source.length;
+ for (let i = 0; i <= len; i += 1) {
+ if (i === len || source.charCodeAt(i) === 0x0a /* \n */) {
+ let isEmpty = true;
+ for (let j = lineStart; j < i; j += 1) {
+ const ch = source.charCodeAt(j);
+ // 0x20=space, 0x09=tab, 0x0D=CR, 0x0B=VT, 0x0C=FF
+ if (ch !== 0x20 && ch !== 0x09 && ch !== 0x0d && ch !== 0x0b && ch !== 0x0c) {
+ isEmpty = false;
+ break;
+ }
+ }
+ if (isEmpty) {
+ if (!emptyLines) {
+ emptyLines = [];
+ }
+ emptyLines.push(totalLines);
+ }
+ if (i < len) {
+ totalLines += 1;
+ lineStart = i + 1;
+ }
+ }
}
- return count;
+ return emptyLines ? { totalLines, emptyLines } : { totalLines };
}
/**
@@ -53,12 +76,18 @@ function countSourceLines(source: string): number {
* When lines are deleted, comments from the deleted range are collapsed
* onto the edit line and recorded in a collapseMap so they can be restored
* if the deletion is undone (lines re-added at the same position).
+ *
+ * Empty/whitespace-only deleted lines are special: since they had no real
+ * content that "shifted upward" into editLine, their comments are pushed
+ * to editLine + 1 (like `-end` boundary markers) so the highlighted region
+ * shrinks instead of shifting onto the previous line.
*/
function shiftComments(
comments: SourceComments | undefined,
lineDelta: number,
position: Position,
existingCollapseMap: CollapseMap | undefined,
+ oldEmptyLines?: number[],
): ShiftResult {
if (!comments || Object.keys(comments).length === 0) {
return { comments, collapseMap: existingCollapseMap };
@@ -122,6 +151,10 @@ function shiftComments(
}
}
+ // O(1) lookup against the precomputed empty-line set from the old source.
+ const oldEmptyLineSet =
+ oldEmptyLines && oldEmptyLines.length > 0 ? new Set(oldEmptyLines) : undefined;
+
for (const [lineStr, commentArr] of Object.entries(comments)) {
const line = Number(lineStr);
if (line <= editLine) {
@@ -147,12 +180,22 @@ function shiftComments(
// so range-end markers stay at the first line after the highlighted range.
// Boundary comments are NOT tracked in collapseMap — they shift normally
// on subsequent edits so the range naturally expands/contracts.
+ //
+ // Empty/whitespace-only deleted lines also push their regular comments
+ // to editLine + 1: nothing actually shifted upward into editLine, so the
+ // highlighted region should shrink rather than expand onto the line above.
+ const wasEmptyLine = oldEmptyLineSet?.has(line) ?? false;
const regular = commentArr.filter((c) => !c.endsWith('-end'));
const boundary = commentArr.filter((c) => c.endsWith('-end'));
if (regular.length > 0) {
- shifted[editLine] = [...(shifted[editLine] ?? []), ...regular];
- newCollapsed.push({ offset: line - editLine, comments: regular });
+ if (wasEmptyLine) {
+ const target = editLine + 1;
+ shifted[target] = [...(shifted[target] ?? []), ...regular];
+ } else {
+ shifted[editLine] = [...(shifted[editLine] ?? []), ...regular];
+ newCollapsed.push({ offset: line - editLine, comments: regular });
+ }
}
if (boundary.length > 0) {
const boundaryTarget = editLine + 1;
@@ -206,13 +249,13 @@ function toControlledCode(code: Code): ControlledCode {
extraFiles = {};
for (const [fileName, entry] of Object.entries(variant.extraFiles)) {
if (typeof entry === 'string') {
- extraFiles[fileName] = { source: entry, totalLines: countSourceLines(entry) };
+ extraFiles[fileName] = { source: entry, ...analyzeSource(entry) };
} else {
const extraSource = entry.source != null ? stringOrHastToString(entry.source) : null;
extraFiles[fileName] = {
source: extraSource,
...(entry.comments ? { comments: entry.comments } : {}),
- totalLines: extraSource != null ? countSourceLines(extraSource) : undefined,
+ ...(extraSource != null ? analyzeSource(extraSource) : {}),
};
}
}
@@ -221,7 +264,7 @@ function toControlledCode(code: Code): ControlledCode {
result[key] = {
...variant,
source,
- totalLines: source != null ? countSourceLines(source) : undefined,
+ ...(source != null ? analyzeSource(source) : {}),
...(extraFiles ? { extraFiles } : {}),
} as ControlledCode[string];
}
@@ -270,21 +313,24 @@ export function useSourceEditing({
if (source === variant.source) {
return currentCode ?? newCode;
}
- const newLineCount = countSourceLines(source);
+ const { totalLines: newLineCount, emptyLines: newEmptyLines } = analyzeSource(source);
const oldLineCount =
- variant.totalLines ?? (variant.source != null ? countSourceLines(variant.source) : 0);
+ variant.totalLines ??
+ (variant.source != null ? analyzeSource(variant.source).totalLines : 0);
const { comments: shiftedComments, collapseMap: newCollapseMap } = position
? shiftComments(
variant.comments,
newLineCount - oldLineCount,
position,
variant.collapseMap,
+ variant.emptyLines,
)
: { comments: undefined, collapseMap: undefined };
newCode[selectedVariantKey] = {
...variant,
source,
totalLines: newLineCount,
+ emptyLines: newEmptyLines,
comments: shiftedComments,
collapseMap: newCollapseMap,
};
@@ -293,16 +339,17 @@ export function useSourceEditing({
if (source === extraEntry?.source) {
return currentCode ?? newCode;
}
- const newLineCount = countSourceLines(source);
+ const { totalLines: newLineCount, emptyLines: newEmptyLines } = analyzeSource(source);
const oldLineCount =
extraEntry?.totalLines ??
- (extraEntry?.source != null ? countSourceLines(extraEntry.source) : 0);
+ (extraEntry?.source != null ? analyzeSource(extraEntry.source).totalLines : 0);
const { comments: shiftedComments, collapseMap: newCollapseMap } = position
? shiftComments(
extraEntry?.comments,
newLineCount - oldLineCount,
position,
extraEntry?.collapseMap,
+ extraEntry?.emptyLines,
)
: { comments: undefined, collapseMap: undefined };
newCode[selectedVariantKey] = {
@@ -313,6 +360,7 @@ export function useSourceEditing({
...extraEntry,
source,
totalLines: newLineCount,
+ emptyLines: newEmptyLines,
comments: shiftedComments,
collapseMap: newCollapseMap,
},
From 0a6358351cc44bdc71ceda3996eba73677ca9540 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Wed, 22 Apr 2026 17:24:59 -0400
Subject: [PATCH 28/45] Prevent firefox enter keydown bug
---
.../docs-infra/src/useCode/useEditable.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 8140b65aa..720454505 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -34,6 +34,7 @@ SOFTWARE.
// - Replace Range.toString() in getPosition with a TreeWalker character count to avoid O(N) string allocation
// - Deduplicate toString() calls via trackState return value
// - Fix Firefox rapid-typing line-loss bug: preserve pre-edit pendingContent across keydowns until flush
+// - Refresh pendingContent baseline after controlled edits so native input following Enter/Tab/Backspace can still be repaired
// - Debounce repeat-key flushes so highlights only re-render once the user pauses typing
// - Fix undo-to-initial-state bug: allow trackState to record before the first flushChanges
// - Fix undo-after-rapid-Enter bug: bypass 500ms dedup on keyup for structural edits (Enter)
@@ -679,6 +680,24 @@ export const useEditable = (
edit.update(newContent);
}
+ // After a controlled edit in plaintext-only contentEditable, the DOM is
+ // in a known-good post-edit state. Refresh pendingContent to that state
+ // so any subsequent native input within the same key burst — e.g.
+ // holding Enter then pressing x in plaintext-only contentEditable, where
+ // `x` falls through to native browser handling and may merge frame
+ // boundary lines — is measured against the correct baseline. Without
+ // this, repairUnexpectedLineMerge sees Enter add a line and the native
+ // merge remove a line for a net zero delta and short-circuits, leaving
+ // the merge unrepaired.
+ //
+ // We gate on `hasPlaintextSupport` because in the Firefox fallback
+ // (contenteditable=true) `edit.insert` itself can trigger the line-merge
+ // quirk, so toString() after it would already be buggy and we must keep
+ // the pre-edit baseline.
+ if (event.defaultPrevented && hasPlaintextSupport) {
+ state.pendingContent = toString(element);
+ }
+
// Flush changes as a key is held so the app can catch up.
// Debounce: reset the timer on each repeat keydown so the expensive
// onChange (syntax re-highlight) only fires once the user pauses typing.
From 4f0ab0c17885446d4ee9739601e954c64a735cad Mon Sep 17 00:00:00 2001
From: dav-is
Date: Wed, 22 Apr 2026 17:27:16 -0400
Subject: [PATCH 29/45] Improve firefox undo stack
---
.../docs-infra/src/useCode/useEditable.ts | 40 ++++++++++++++-----
1 file changed, 29 insertions(+), 11 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 720454505..a2d4ec002 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -35,6 +35,7 @@ SOFTWARE.
// - Deduplicate toString() calls via trackState return value
// - Fix Firefox rapid-typing line-loss bug: preserve pre-edit pendingContent across keydowns until flush
// - Refresh pendingContent baseline after controlled edits so native input following Enter/Tab/Backspace can still be repaired
+// - Record repaired (not raw) content into the undo stack so Firefox merge intermediates don't pollute history
// - Debounce repeat-key flushes so highlights only re-render once the user pauses typing
// - Fix undo-to-initial-state bug: allow trackState to record before the first flushChanges
// - Fix undo-after-rapid-Enter bug: bypass 500ms dedup on keyup for structural edits (Enter)
@@ -510,7 +511,11 @@ export const useEditable = (
const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`);
let trackStateTimestamp: number;
- const trackState = (ignoreTimestamp?: boolean): string | null => {
+ const trackState = (
+ ignoreTimestamp?: boolean,
+ contentOverride?: string,
+ positionOverride?: Position,
+ ): string | null => {
// Require a live selection so getPosition() (which calls getRangeAt(0)) is safe.
// Using !state.position would block recording the initial state: state.position is
// only set by flushChanges() which runs on keyup — after the first edit. Switching
@@ -519,8 +524,12 @@ export const useEditable = (
return null;
}
- const content = toString(element);
- const position = getPosition(element);
+ // Callers may pass in already-computed (and possibly repaired) content so
+ // we don't re-read a buggy intermediate DOM. flushChanges uses this to
+ // record the repaired post-edit state instead of the merged DOM that
+ // Firefox/observer left behind.
+ const content = contentOverride ?? toString(element);
+ const position = positionOverride ?? getPosition(element);
const timestamp = new Date().valueOf();
// Prevent recording new state in list if last one has been new enough
@@ -549,7 +558,7 @@ export const useEditable = (
state.disconnected = true;
};
- const flushChanges = () => {
+ const flushChanges = (ignoreTimestamp?: boolean) => {
const records = observerRef.current?.takeRecords() ?? [];
state.queue.push(...records);
const position = getPosition(element);
@@ -576,6 +585,12 @@ export const useEditable = (
}
}
+ // Record the REPAIRED content into history before notifying the app.
+ // Reading toString() back from the DOM here would capture the buggy
+ // pre-repair state (e.g. a Firefox line-merge), which is what was
+ // previously polluting the undo stack.
+ trackState(ignoreTimestamp, content, position);
+
state.onChange(content, position);
}
@@ -722,13 +737,17 @@ export const useEditable = (
clearTimeout(state.repeatFlushId);
state.repeatFlushId = null;
}
+ // Structural edits (Enter) must always create their own undo checkpoint.
+ // Regular character typing uses the 500ms dedup so you undo a word at a
+ // time, but each Enter should be individually undoable. flushChanges
+ // records the (repaired) post-edit content into history before firing
+ // onChange, so we don't poison the undo stack with intermediate
+ // browser-merged DOM states.
if (!isUndoRedoKey(event)) {
- // Structural edits (Enter) must always create their own undo checkpoint.
- // Regular character typing uses the 500ms dedup so you undo a word at a
- // time, but each Enter should be individually undoable.
- trackState(event.key === 'Enter');
+ flushChanges(event.key === 'Enter');
+ } else {
+ flushChanges();
}
- flushChanges();
// Chrome Quirk: The contenteditable may lose focus after the first edit or so
element.focus();
};
@@ -743,8 +762,7 @@ export const useEditable = (
event.preventDefault();
state.pendingContent = trackState(true) ?? toString(element);
edit.insert(event.clipboardData!.getData('text/plain'));
- trackState(true);
- flushChanges();
+ flushChanges(true);
};
document.addEventListener('selectstart', onSelect);
From e1aa3c37006a544c5b78f375bf31461afb331ec0 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Wed, 22 Apr 2026 18:01:32 -0400
Subject: [PATCH 30/45] bump version
---
packages/docs-infra/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/docs-infra/package.json b/packages/docs-infra/package.json
index e8dc8c1bb..c2b8e3dcb 100644
--- a/packages/docs-infra/package.json
+++ b/packages/docs-infra/package.json
@@ -1,6 +1,6 @@
{
"name": "@mui/internal-docs-infra",
- "version": "0.9.0",
+ "version": "0.11.0",
"author": "MUI Team",
"description": "MUI Infra - internal documentation creation tools.",
"license": "MIT",
From d67f138ccc96d574d8b7847dd5531bbeaa359fc5 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Fri, 24 Apr 2026 09:51:47 -0400
Subject: [PATCH 31/45] Add missing `@focus`
---
.../components/code-controller-context/demos/code/page.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx
index 11f5bb8cf..6891f8544 100644
--- a/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx
+++ b/docs/app/bench/docs-infra/components/code-controller-context/demos/code/page.tsx
@@ -11,6 +11,7 @@ const sourceParser = createParseSource();
export default function Page() {
return (
+ // @focus-start
+ // @focus-end
);
}
From bc9b0b6ba3ca39e0d7fcb766f609c0f0802497de Mon Sep 17 00:00:00 2001
From: dav-is
Date: Sat, 25 Apr 2026 18:39:37 -0400
Subject: [PATCH 32/45] Fix lint
---
.../demos/demo-live/DemoLiveContent.module.css | 4 ----
1 file changed, 4 deletions(-)
diff --git a/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.module.css b/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.module.css
index 8eecf03b2..f9dd0d739 100644
--- a/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.module.css
+++ b/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.module.css
@@ -86,10 +86,6 @@
outline: none;
}
-.codeBlock :global(.frame) {
- display: block;
-}
-
.codeBlock :global(.frame)[data-frame-type='highlighted'] {
background: #ebe4ff;
border-radius: 8px;
From 1a6585eba6f591b9df1e342dada33f7455980192 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 17:27:42 -0400
Subject: [PATCH 33/45] Fix resetFocus()
Co-authored-by: Copilot
---
docs/app/docs-infra/hooks/use-demo/page.mdx | 45 ++++++++++++++++++++-
packages/docs-infra/src/useDemo/useDemo.ts | 10 +++--
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/docs/app/docs-infra/hooks/use-demo/page.mdx b/docs/app/docs-infra/hooks/use-demo/page.mdx
index a1aa5fd99..9be6c7338 100644
--- a/docs/app/docs-infra/hooks/use-demo/page.mdx
+++ b/docs/app/docs-infra/hooks/use-demo/page.mdx
@@ -28,7 +28,7 @@ export function DemoContent(props) {
const demo = useDemo(props, { preClassName: styles.codeBlock });
return (
-
+
{/* Component Preview Section */}
{demo.component}
@@ -555,6 +555,49 @@ export function DemoWithLanguageToggle(props) {
}
```
+### Reset Focus After Interaction
+
+`useDemo` exposes a `focusRef` and a `resetFocus` callback for sending
+keyboard focus back into the demo — useful after the user interacts with
+toolbar controls (variant pickers, copy buttons, sandbox links, etc.) so a
+follow-up Tab keypress lands inside the rendered component instead of the
+next toolbar control.
+
+The typical pattern is to render an invisible focus-target button at the
+top of the preview area:
+
+```tsx
+export function DemoContent(props) {
+ const demo = useDemo(props, { preClassName: styles.codeBlock });
+
+ return (
+
diff --git a/docs/app/docs-infra/hooks/use-demo/types.md b/docs/app/docs-infra/hooks/use-demo/types.md
index 7276777c3..c30fea996 100644
--- a/docs/app/docs-infra/hooks/use-demo/types.md
+++ b/docs/app/docs-infra/hooks/use-demo/types.md
@@ -221,7 +221,7 @@ type ReturnValue = void;
| Property | Type | Description |
| :------------------ | :------------------------------------------------------------------------------------------------------------------------------- | :---------- |
| component | `string \| number \| bigint \| true \| ReactElement \| Iterable \| Promise \| null` | - |
-| ref | `React.RefObject` | - |
+| focusRef | `React.RefObject` | - |
| resetFocus | `(() => void)` | - |
| openStackBlitz | `(() => void)` | - |
| openCodeSandbox | `(() => void)` | - |
From 449ad9906e289719f6abf68e060060ff32d08edf Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 18:50:26 -0400
Subject: [PATCH 35/45] Opt into indenting
Co-authored-by: Copilot
---
.../demos/CodeIndent.tsx | 13 ++--
.../pipeline/enhance-code-emphasis/page.mdx | 5 +-
.../pipeline/enhance-code-emphasis/types.md | 9 +++
.../enhanceCodeEmphasis.test.ts | 64 ++++++++++++-------
.../enhanceCodeEmphasis.ts | 9 ++-
.../parseSource/calculateFrameRanges.ts | 9 +++
6 files changed, 78 insertions(+), 31 deletions(-)
diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CodeIndent.tsx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CodeIndent.tsx
index f262e1d4f..389263282 100644
--- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CodeIndent.tsx
+++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CodeIndent.tsx
@@ -4,19 +4,22 @@ import * as React from 'react';
import { CodeHighlighter } from '@mui/internal-docs-infra/CodeHighlighter';
import type { Code as CodeType } from '@mui/internal-docs-infra/CodeHighlighter/types';
import { createParseSource } from '@mui/internal-docs-infra/pipeline/parseSource';
-import { enhanceCodeEmphasis } from '@mui/internal-docs-infra/pipeline/enhanceCodeEmphasis';
+import { createEnhanceCodeEmphasis } from '@mui/internal-docs-infra/pipeline/enhanceCodeEmphasis';
import { IndentContent } from './IndentContent';
const sourceParser = createParseSource();
-const sourceEnhancers = [enhanceCodeEmphasis];
+// `emitFrameIndent: true` opts in to the `data-frame-indent` attribute that
+// the CSS below uses to shift the focused frame left when collapsed.
+const sourceEnhancers = [createEnhanceCodeEmphasis({ emitFrameIndent: true })];
/**
* A server component that renders a collapsible code block with indent shifting.
*
- * Uses the default `enhanceCodeEmphasis` (no padding) so only `highlighted`
- * and normal frames are produced. The `data-frame-indent` attribute on
- * highlighted frames drives the CSS-based left shift.
+ * Uses `createEnhanceCodeEmphasis({ emitFrameIndent: true })` (no padding) so
+ * only `highlighted`/`focus` and normal frames are produced, and each region
+ * frame carries a `data-frame-indent` attribute that drives the CSS-based
+ * left shift.
*/
export function CodeIndent({ code }: { code: CodeType }) {
return (
diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
index aed60e8d4..c7947bb83 100644
--- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
+++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
@@ -372,7 +372,7 @@ When padding is configured (see [Configurable Padding](#configurable-padding)),
| Attribute | Type | Description |
| ------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-frame-type` | string | `"highlighted"` (focused region with line highlights), `"highlighted-unfocused"` (non-focused regions with line highlights), `"focus"` (focused region without line highlights), `"focus-unfocused"` (non-focused region without line highlights), `"padding-top"`, or `"padding-bottom"`. Normal frames have no type attribute. |
-| `data-frame-indent` | number | Only on `highlighted`, `highlighted-unfocused`, `focus`, and `focus-unfocused` frames. The shared indent level of the highlighted lines (min leading spaces ÷ 2). |
+| `data-frame-indent` | number | Only on `highlighted`, `highlighted-unfocused`, `focus`, and `focus-unfocused` frames, and only when `createEnhanceCodeEmphasis({ emitFrameIndent: true })` is used. The shared indent level of the highlighted lines (min leading spaces ÷ 2). |
| `data-frame-description` | string | Present on highlighted frames when the `@highlight` or `@highlight-start` directive includes a description (e.g., `@highlight "description"`). Not set when highlighting is at the line level (inside a focus region or when strong). |
### Configurable Padding
@@ -395,6 +395,7 @@ const sourceEnhancers = [
| `paddingFrameMaxSize` | number | `0` | Maximum number of context lines above/below the focused region |
| `focusFramesMaxSize` | number | — | Maximum total lines in the focus area (padding + region). When the region fits, remaining budget is split floor/ceil between padding-top and padding-bottom. When the region exceeds this limit, a focused window is taken from the start of the region, and the remaining overflow lines are marked as unfocused. |
| `strictHighlightText` | boolean | `false` | When `true`, throws an error if a `@highlight-text` match has to be fragmented across element boundaries (producing `data-hl-part` spans). Highlights that wrap multiple complete elements in a single `data-hl` span are still allowed. |
+| `emitFrameIndent` | boolean | `false` | When `true`, region frames (`highlighted`, `focus`, and their unfocused variants) receive a `data-frame-indent` attribute carrying the shared indent level of the highlighted lines. Useful for CSS-driven indent shifting in collapsed views (see [Indent Shifting](#indent-shifting)). |
When `paddingFrameMaxSize` is `0` (the default), no padding frames are created and only region frames (`highlighted`, `focus`, etc.) and normal frames are produced.
@@ -627,7 +628,7 @@ import { DemoFocusCode } from './demos/focus-code';
### Indent Shifting
-With no padding configured, only highlighted/focus and normal frames are produced. The `data-frame-indent` attribute on region frames tells CSS how far the code is indented. This demo highlights an import statement (indent 0) and uses standalone `@focus-start`/`@focus-end` around the deeply nested `` usage (indent 3). When collapsed, the focused frame shifts left by `6ch` so it aligns with the left edge, then resets when expanded.
+With no padding configured, only highlighted/focus and normal frames are produced. Opt in to the `data-frame-indent` attribute on region frames by passing `emitFrameIndent: true` to `createEnhanceCodeEmphasis` — CSS can then read it to know how far the code is indented. This demo highlights an import statement (indent 0) and uses standalone `@focus-start`/`@focus-end` around the deeply nested `` usage (indent 3). When collapsed, the focused frame shifts left by `6ch` so it aligns with the left edge, then resets when expanded.
import { DemoIndentCode } from './demos/indent-code';
diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/types.md b/docs/app/docs-infra/pipeline/enhance-code-emphasis/types.md
index 4a2b03392..7f02faec2 100644
--- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/types.md
+++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/types.md
@@ -122,6 +122,15 @@ type EnhanceCodeEmphasisOptions = {
* allowed — only boundary-straddling matches are rejected.
*/
strictHighlightText?: boolean;
+ /**
+ * When `true`, emits a `data-frame-indent` attribute on highlighted/focus
+ * region frames indicating the shared leading indent level. Consumers can
+ * use this to visually shift collapsible regions horizontally when
+ * surrounding context lines are hidden. Off by default since most demos
+ * don't need it and it bloats the rendered HTML.
+ * @default false
+ */
+ emitFrameIndent?: boolean;
};
```
diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
index bc8433d9d..8e6a437f4 100644
--- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
+++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
@@ -12,6 +12,10 @@ import type { HastRoot, ParseSource, SourceEnhancer } from '../../CodeHighlighte
/**
* Test helper to parse code, enhance it, and return HTML via rehype-stringify.
+ *
+ * Uses the bare `enhanceCodeEmphasis` export by default, which reflects the
+ * out-of-the-box behavior (no `data-frame-indent` emitted). Tests that need
+ * different options should pass an explicit `enhancer`.
*/
async function testEmphasis(
code: string,
@@ -78,7 +82,7 @@ describe('enhanceCodeEmphasis', () => {
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionButton() {return (
- <buttonclassName="primary">Click me</button>
+ <buttonclassName="primary">Click me</button> );}"
`);
@@ -96,9 +100,9 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(
`
- "consta=1;
+ "consta=1;constb=2;
- constc=3;
+ constc=3;constd=4;"
`,
@@ -116,7 +120,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionComponent() {
- const [count, setCount] =useState(0);
+ const [count, setCount] =useState(0);return <div>{count}</div>;}"
`);
@@ -133,7 +137,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionComponent() {
- consturl=getUrl();
+ consturl=getUrl();return <ahref={url}>Link</a>;}"
`);
@@ -159,7 +163,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionComponent() {return (
- <div>
+ <div> <h1>Heading 1</h1> <p>Some content</p> </div>
@@ -199,7 +203,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(
`
"functiontest() {
- returnnull;
+ returnnull;}"
`,
);
@@ -222,7 +226,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionComponent() {return (
- <div>
+ <div> <h1>Heading 1</h1> </div> );
@@ -248,7 +252,7 @@ const e = 5; // @highlight`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionComponent() {
- return (
+ return ( <div> <h1>Heading 1</h1> </div>
@@ -272,7 +276,7 @@ const e = 5; // @highlight`,
);
expect(result).toMatchInlineSnapshot(`
- "exportdefaultfunctionComponent() {
+ "exportdefaultfunctionComponent() {return ( <div> <h1>Heading 1</h1>
@@ -297,7 +301,7 @@ const e = 5; // @highlight`,
// Both "primary" and "Heading 1" should be wrapped in data-hl spans
// Comments are stripped, so only code matches appear
expect(result).toMatchInlineSnapshot(`
- "exportdefaultfunctionComponent() {
+ "exportdefaultfunctionComponent() {return ( <div> <h1className="primary">Heading 1</h1>
@@ -328,7 +332,7 @@ const e = 5; // @highlight`,
);
expect(result).toMatchInlineSnapshot(
- `"<AlertDialog.Triggerhandle={demoAlertDialog}>Open</AlertDialog.Trigger>"`,
+ `"<AlertDialog.Triggerhandle={demoAlertDialog}>Open</AlertDialog.Trigger>"`,
);
});
@@ -340,7 +344,7 @@ const e = 5; // @highlight`,
);
expect(result).toMatchInlineSnapshot(
- `"<Buttonrender={<div />}nativeButton={false}>"`,
+ `"<Buttonrender={<div />}nativeButton={false}>"`,
);
});
@@ -468,7 +472,7 @@ const e = 5; // @highlight`,
);
expect(result).toMatchInlineSnapshot(
- `"<Input value={a} /> <Input value={b} /> // @highlight-text "value""`,
+ `"<Input value={a} /> <Input value={b} /> // @highlight-text "value""`,
);
});
@@ -495,7 +499,7 @@ const e = 5; // @highlight`,
);
expect(result).toMatchInlineSnapshot(
- `"<Input value={value} />"`,
+ `"<Input value={value} />"`,
);
});
});
@@ -597,9 +601,9 @@ const another = 99; // @highlight`,
);
expect(result).toMatchInlineSnapshot(`
- "constvalue=42;
+ "constvalue=42;functionexample() {
- constx=1;
+ constx=1;consty=2;returnx+y;}
@@ -773,7 +777,7 @@ const b = 2;`,
"exportdefaultfunctionComponent() {return ( <div>
- <h1>Heading 1</h1>
+ <h1>Heading 1</h1> <p>Content</p> </div> );
@@ -801,11 +805,11 @@ const b = 2;`,
expect(result).toMatchInlineSnapshot(`
"exportdefaultfunctionDashboard() {
- const [data, setData] =useState([]);
+ const [data, setData] =useState([]);return ( <div> <Header />
- <Chartdata={data} />
+ <Chartdata={data} /> <Tabledata={data} /> <Footer /> </div>
@@ -845,7 +849,7 @@ const b = 2;`,
}
}
- // Parse the code WITH comments still present, then enhance
+ // Parse the code WITH comments still present, then enhance.
const root = await parseSourceFn(code, fileName);
const enhanced = enhanceCodeEmphasis(root, comments, fileName) as HastRoot;
@@ -961,7 +965,7 @@ const z = 3;`,
);
expect(result).toMatchInlineSnapshot(`
- "exportdefaultfunctionComponent() {
+ "exportdefaultfunctionComponent() {return ( <div> <h1className="primary">Heading 1</h1> {/* @highlight-text "primary" "Heading 1" */}
@@ -1092,6 +1096,7 @@ const e = 5;`,
it('should support @focus on @highlight-start', async () => {
const enhancer = createEnhanceCodeEmphasis({
paddingFrameMaxSize: 1,
+ emitFrameIndent: true,
});
const result = await testEmphasis(
@@ -1155,10 +1160,25 @@ const d = 4;`,
return null;
}`,
parseSource,
+ 'test.tsx',
+ createEnhanceCodeEmphasis({ emitFrameIndent: true }),
);
// Lines 2-3 are highlighted with 4 spaces indent, indent level = 4/2 = 2
expect(result).toMatch(/data-frame-type="highlighted" data-frame-indent="2"/);
});
+
+ it('should not emit data-frame-indent by default', async () => {
+ const result = await testEmphasis(
+ `function test() {
+ const a = 1; // @highlight
+ return null;
+}`,
+ parseSource,
+ );
+
+ expect(result).toContain('data-frame-type="highlighted"');
+ expect(result).not.toContain('data-frame-indent');
+ });
});
});
diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
index 4e30db91c..6228223ea 100644
--- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
+++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
@@ -1547,8 +1547,13 @@ export function createEnhanceCodeEmphasis(
effectiveOptions,
);
- // Step 5: Calculate indent levels per region (uses collected elements, no tree traversal)
- const regionIndentLevels = calculateRegionIndentLevels(highlightedElements, emphasizedLines);
+ // Step 5: Calculate indent levels per region (uses collected elements, no tree traversal).
+ // Skipped when `emitFrameIndent` is off (the default) so we don't pay the
+ // cost or pollute the HAST with `data-frame-indent` attributes that no
+ // consumer reads.
+ const regionIndentLevels = effectiveOptions.emitFrameIndent
+ ? calculateRegionIndentLevels(highlightedElements, emphasizedLines)
+ : new Map();
// Step 6: Calculate frame ranges (pure math, no tree traversal)
// Filter out text-only lines that don't need their own frames.
diff --git a/packages/docs-infra/src/pipeline/parseSource/calculateFrameRanges.ts b/packages/docs-infra/src/pipeline/parseSource/calculateFrameRanges.ts
index 1f080d8ae..197ef82b2 100644
--- a/packages/docs-infra/src/pipeline/parseSource/calculateFrameRanges.ts
+++ b/packages/docs-infra/src/pipeline/parseSource/calculateFrameRanges.ts
@@ -105,6 +105,15 @@ export interface EnhanceCodeEmphasisOptions {
* allowed — only boundary-straddling matches are rejected.
*/
strictHighlightText?: boolean;
+ /**
+ * When `true`, emits a `data-frame-indent` attribute on highlighted/focus
+ * region frames indicating the shared leading indent level. Consumers can
+ * use this to visually shift collapsible regions horizontally when
+ * surrounding context lines are hidden. Off by default since most demos
+ * don't need it and it bloats the rendered HTML.
+ * @default false
+ */
+ emitFrameIndent?: boolean;
}
/** Default max number of lines kept in focus when not explicitly configured. */
From fd20fc3faa09de86fb6ddf38cddb3f8a8d67c4ba Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 18:59:59 -0400
Subject: [PATCH 36/45] Improve collapsed editable behavior
Co-authored-by: Copilot
---
packages/docs-infra/src/useCode/Pre.tsx | 121 +++++-
packages/docs-infra/src/useCode/useCode.ts | 2 +
.../src/useCode/useEditable.test.ts | 373 +++++++++++++++++-
.../docs-infra/src/useCode/useEditable.ts | 154 ++++++++
.../src/useCode/useFileNavigation.tsx | 24 ++
5 files changed, 668 insertions(+), 6 deletions(-)
diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx
index 8ce615a83..268ea87f6 100644
--- a/packages/docs-infra/src/useCode/Pre.tsx
+++ b/packages/docs-infra/src/useCode/Pre.tsx
@@ -48,6 +48,83 @@ function getInitialVisibleFrames(hast: HastRoot | null): { [key: number]: boolea
return visibleFrames;
}
+/**
+ * Bounds describing the visible region of a collapsible code block in its
+ * collapsed state. Used to constrain caret movement in `useEditable` and to
+ * trigger expansion when the user navigates past the boundaries.
+ */
+type CollapsedBounds = {
+ /**
+ * Smallest column the visible region exposes on indented lines (derived
+ * from the minimum `data-frame-indent` across collapsed-visible region
+ * frames). `undefined` when no visible region frame is indented.
+ */
+ minColumn: number | undefined;
+ /** First row of the visible region. */
+ minRow: number;
+ /** Last row of the visible region. */
+ maxRow: number;
+};
+
+/**
+ * When the code block is collapsible, returns the row range of the
+ * collapsed-visible frames (the same set used by `getInitialVisibleFrames`)
+ * along with the minimum indent column. Returns `undefined` when the block
+ * isn't collapsible or no frame is visible-when-collapsed.
+ *
+ * `data-frame-indent` is encoded as `leadingSpaces / indentation`, so the
+ * column count is `indent * indentation`.
+ */
+function computeCollapsedBounds(
+ hast: HastRoot | null,
+ indentation: number,
+): CollapsedBounds | undefined {
+ if (!hast || hast.data?.collapsible !== true) {
+ return undefined;
+ }
+
+ let minIndent: number | undefined;
+ let minRow: number | undefined;
+ let maxRow: number | undefined;
+ let row = 0;
+
+ for (const child of hast.children) {
+ if (child.type !== 'element' || child.properties.className !== 'frame') {
+ continue;
+ }
+ const frameType = child.properties.dataFrameType;
+ const indent = child.properties.dataFrameIndent;
+ const isVisibleWhenCollapsed =
+ typeof frameType === 'string' && INITIAL_VISIBLE_FRAME_TYPES.has(frameType);
+
+ const text = toText(child, { whitespace: 'pre' });
+ const newlines = text.length > 0 ? text.split('\n').length - 1 : 0;
+ const lastContentRow = text.endsWith('\n') ? row + Math.max(0, newlines - 1) : row + newlines;
+
+ if (isVisibleWhenCollapsed) {
+ if (minRow === undefined) {
+ minRow = row;
+ }
+ maxRow = lastContentRow;
+ if (typeof indent === 'number' && (minIndent === undefined || indent < minIndent)) {
+ minIndent = indent;
+ }
+ }
+
+ row += newlines;
+ }
+
+ if (minRow === undefined || maxRow === undefined) {
+ return undefined;
+ }
+
+ return {
+ minColumn: minIndent !== undefined && minIndent > 0 ? minIndent * indentation : undefined,
+ minRow,
+ maxRow,
+ };
+}
+
function renderCode(hastChildren: ElementContent[], renderHast?: boolean, text?: string) {
if (renderHast) {
let jsx = hastChildrenCache.get(hastChildren);
@@ -79,6 +156,8 @@ export function Pre({
setSource,
shouldHighlight,
hydrateMargin = '200px 0px 200px 0px',
+ expanded = false,
+ expand,
}: {
children: VariantSource;
className?: string;
@@ -88,6 +167,19 @@ export function Pre({
setSource?: (source: string, fileName?: string, position?: Position) => void;
shouldHighlight?: boolean;
hydrateMargin?: string;
+ /**
+ * Whether the host has expanded the (collapsible) code block. When `true`,
+ * collapsed-state behaviors such as `minColumn` are disabled so the caret
+ * can move into the indent gutter normally.
+ */
+ expanded?: boolean;
+ /**
+ * Called when the user attempts to navigate the caret past the visible
+ * region of a collapsed code block (e.g. `ArrowUp` on the first visible
+ * row, `ArrowDown` on the last). Typically wired to the host's
+ * `expand()` action.
+ */
+ expand?: () => void;
}): React.ReactNode {
const isEditable = Boolean(setSource);
@@ -126,15 +218,34 @@ export function Pre({
[setSource, fileName],
);
- useEditable(preRef, onEditableChange, {
- indentation: 2,
- disabled: !setSource || !editableReady,
- });
-
const [visibleFrames, setVisibleFrames] = React.useState<{ [key: number]: boolean }>(() =>
getInitialVisibleFrames(hast),
);
+ // When the code block is collapsible AND currently collapsed, derive the
+ // visible region's row range and minimum indent column so that:
+ // - the caret never lands in the clipped indent gutter (`minColumn`),
+ // - arrow-key navigation past the visible region is blocked, optionally
+ // calling `expand` so the host can reveal the hidden content.
+ // See `computeCollapsedBounds` above.
+ //
+ // `data-frame-indent` is encoded as `leadingSpaces / 2`, matching the
+ // hardcoded `indentation: 2` below.
+ const indentation = 2;
+ const collapsedBounds = React.useMemo(
+ () => (expanded ? undefined : computeCollapsedBounds(hast, indentation)),
+ [hast, expanded],
+ );
+
+ useEditable(preRef, onEditableChange, {
+ indentation,
+ disabled: !setSource || !editableReady,
+ minColumn: collapsedBounds?.minColumn,
+ minRow: collapsedBounds?.minRow,
+ maxRow: collapsedBounds?.maxRow,
+ onBoundary: collapsedBounds && expand ? expand : undefined,
+ });
+
const observer = React.useRef(null);
const frameIndexMap = React.useRef(new WeakMap());
const bindIntersectionObserver = React.useCallback(
diff --git a/packages/docs-infra/src/useCode/useCode.ts b/packages/docs-infra/src/useCode/useCode.ts
index 999212396..ed37ef3f3 100644
--- a/packages/docs-infra/src/useCode/useCode.ts
+++ b/packages/docs-infra/src/useCode/useCode.ts
@@ -196,6 +196,8 @@ export function useCode(
saveVariantToLocalStorage: variantSelection.saveVariantToLocalStorage,
hashVariant: variantSelection.hashVariant,
sourceEnhancers: mergedEnhancers,
+ expanded: uiState.expanded,
+ expand: uiState.expand,
});
// Sub-hook: Copy Functionality
diff --git a/packages/docs-infra/src/useCode/useEditable.test.ts b/packages/docs-infra/src/useCode/useEditable.test.ts
index 85aa70318..70078a56d 100644
--- a/packages/docs-infra/src/useCode/useEditable.test.ts
+++ b/packages/docs-infra/src/useCode/useEditable.test.ts
@@ -42,7 +42,17 @@ function placeSelection(element: HTMLElement, offset: number, extent = 0) {
/**
* Renders `useEditable` bound to a real `
` element and returns helpers.
*/
-function setup(initialContent: string, opts: { disabled?: boolean; indentation?: number } = {}) {
+function setup(
+ initialContent: string,
+ opts: {
+ disabled?: boolean;
+ indentation?: number;
+ minColumn?: number;
+ minRow?: number;
+ maxRow?: number;
+ onBoundary?: () => void;
+ } = {},
+) {
const element = document.createElement('pre');
element.textContent = initialContent;
document.body.appendChild(element);
@@ -868,6 +878,367 @@ describe('useEditable', () => {
});
});
+ // ---------------------------------------------------------------------------
+ // minColumn option
+ // ---------------------------------------------------------------------------
+ describe('minColumn option', () => {
+ function getCaretPosition(element: HTMLElement): number {
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ return pre.toString().length;
+ }
+
+ it('moves ArrowLeft at minColumn to end of previous line', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ // Caret at column 4 of line 1 (right after the indent, on the "w")
+ placeSelection(element, 'hello\n '.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(getCaretPosition(element)).toBe('hello'.length);
+ });
+
+ it('moves ArrowRight at end of line to minColumn of next line', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ // Caret at end of line 0
+ placeSelection(element, 'hello'.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(getCaretPosition(element)).toBe('hello\n '.length);
+ });
+
+ it('does not intercept ArrowLeft when caret is past minColumn', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ // Caret at column 5 of line 1 (one char into "world")
+ placeSelection(element, 'hello\n w'.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept ArrowRight when caret is not at end of line', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ // Caret in the middle of line 0
+ placeSelection(element, 2);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept ArrowRight when next line is not indented to minColumn', () => {
+ const { element } = setup('hello\nhi', { minColumn: 4 });
+ // Caret at end of line 0; next line "hi" has only 0 indent
+ placeSelection(element, 'hello'.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept ArrowLeft when current line indent is shorter than minColumn', () => {
+ // Caret happens to be at column 4 but the line has non-whitespace within
+ // the first 4 chars — this is not the "in the indent" case.
+ const { element } = setup('hello\nabcdef', { minColumn: 4 });
+ placeSelection(element, 'hello\nabcd'.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept arrow keys when shift is held (selection extension)', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ placeSelection(element, 'hello\n '.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept ArrowLeft on the first line', () => {
+ const { element } = setup(' world', { minColumn: 4 });
+ placeSelection(element, ' '.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does nothing when minColumn is undefined', () => {
+ const { element } = setup('hello\n world');
+ placeSelection(element, 'hello\n '.length);
+
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // minRow / maxRow / onBoundary options
+ // ---------------------------------------------------------------------------
+ describe('visible row bounds', () => {
+ function getCaretPosition(element: HTMLElement): number {
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ return pre.toString().length;
+ }
+
+ function dispatchKey(element: HTMLElement, key: string, modifiers: KeyboardEventInit = {}) {
+ const event = new KeyboardEvent('keydown', {
+ key,
+ bubbles: true,
+ cancelable: true,
+ ...modifiers,
+ });
+ element.dispatchEvent(event);
+ return event;
+ }
+
+ describe('ArrowUp at minRow', () => {
+ it('invokes onBoundary and allows native caret movement', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
+ // Caret at start of row 1 ("b")
+ placeSelection(element, 'a\n'.length);
+
+ const event = dispatchKey(element, 'ArrowUp');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke onBoundary on rows after minRow', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
+ // Caret in row 2 ("c")
+ placeSelection(element, 'a\nb\n'.length);
+
+ const event = dispatchKey(element, 'ArrowUp');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).not.toHaveBeenCalled();
+ });
+
+ it('does not invoke onBoundary when shift is held (selection)', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
+ placeSelection(element, 'a\n'.length);
+
+ const event = dispatchKey(element, 'ArrowUp', { shiftKey: true });
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).not.toHaveBeenCalled();
+ });
+
+ it('blocks when onBoundary is not provided', () => {
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2 });
+ placeSelection(element, 'a\n'.length);
+ const before = getCaretPosition(element);
+
+ const event = dispatchKey(element, 'ArrowUp');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(getCaretPosition(element)).toBe(before);
+ });
+ });
+
+ describe('ArrowDown at maxRow', () => {
+ it('invokes onBoundary and allows native caret movement', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
+ // Caret in row 2 ("c")
+ placeSelection(element, 'a\nb\n'.length);
+
+ const event = dispatchKey(element, 'ArrowDown');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke onBoundary on rows before maxRow', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2, onBoundary });
+ placeSelection(element, 'a\n'.length);
+
+ const event = dispatchKey(element, 'ArrowDown');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).not.toHaveBeenCalled();
+ });
+
+ it('blocks when onBoundary is not provided', () => {
+ const { element } = setup('a\nb\nc\nd', { minRow: 1, maxRow: 2 });
+ placeSelection(element, 'a\nb\n'.length);
+
+ const event = dispatchKey(element, 'ArrowDown');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('ArrowLeft at start of minRow', () => {
+ it('invokes onBoundary and allows native caret movement at column 0', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
+ // Caret at column 0 of row 1
+ placeSelection(element, 'a\n'.length);
+
+ const event = dispatchKey(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('invokes onBoundary at minColumn on indented row', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\n bcd\ne', {
+ minColumn: 4,
+ minRow: 1,
+ maxRow: 1,
+ onBoundary,
+ });
+ // Caret at column minColumn (4) of row 1, lined up with "b"
+ placeSelection(element, 'a\n '.length);
+
+ const event = dispatchKey(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks when onBoundary is not provided', () => {
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1 });
+ placeSelection(element, 'a\n'.length);
+
+ const event = dispatchKey(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('does not invoke onBoundary mid-line on minRow', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
+ // Caret in middle of row 1
+ placeSelection(element, 'a\nb'.length);
+
+ const event = dispatchKey(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('ArrowRight at end of maxRow', () => {
+ it('invokes onBoundary and allows native caret movement at end of line', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
+ // Caret at end of row 1
+ placeSelection(element, 'a\nbcd'.length);
+
+ const event = dispatchKey(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks when onBoundary is not provided', () => {
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1 });
+ placeSelection(element, 'a\nbcd'.length);
+
+ const event = dispatchKey(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('does not invoke onBoundary mid-line on maxRow', () => {
+ const onBoundary = vi.fn();
+ const { element } = setup('a\nbcd\ne', { minRow: 1, maxRow: 1, onBoundary });
+ // Caret mid-row
+ placeSelection(element, 'a\nb'.length);
+
+ const event = dispatchKey(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).not.toHaveBeenCalled();
+ });
+
+ it('takes precedence over minColumn next-line jump', () => {
+ const onBoundary = vi.fn();
+ // maxRow == 1, next row indented to minColumn — boundary should win.
+ const { element } = setup('a\nbcd\n e', {
+ minColumn: 4,
+ minRow: 1,
+ maxRow: 1,
+ onBoundary,
+ });
+ placeSelection(element, 'a\nbcd'.length);
+
+ const event = dispatchKey(element, 'ArrowRight');
+
+ // With onBoundary provided, native movement is allowed; the
+ // useEditable-driven jump to minColumn of the next line is skipped.
+ expect(event.defaultPrevented).toBe(false);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index a2d4ec002..ac73e9e3e 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -40,6 +40,8 @@ SOFTWARE.
// - Fix undo-to-initial-state bug: allow trackState to record before the first flushChanges
// - Fix undo-after-rapid-Enter bug: bypass 500ms dedup on keyup for structural edits (Enter)
// - Fix React 19 compatibility: useState lazy init for edit, useRef for MutationObserver, window SSR guard
+// - Add `minColumn` option: skip clipped indent gutter via horizontal arrow navigation
+// - Add `minRow`/`maxRow`/`onBoundary` options: detect arrow-key navigation past the visible region; allow native movement when `onBoundary` is provided so hosts can expand collapsed regions without losing focus
import * as React from 'react';
@@ -326,6 +328,48 @@ interface State {
export interface Options {
disabled?: boolean;
indentation?: number;
+ /**
+ * Minimum column the cursor is allowed to occupy on indented lines.
+ *
+ * When set, horizontal arrow navigation skips over the leading whitespace
+ * up to `minColumn` so the caret never lands inside a clipped/hidden
+ * indent region:
+ *
+ * - `ArrowLeft` at column `minColumn` (with that line's first `minColumn`
+ * characters all whitespace) jumps to the end of the previous line
+ * instead of stepping into the indent.
+ * - `ArrowRight` at the end of a line jumps to column `minColumn` of the
+ * next line (when the next line is indented at least that far) instead
+ * of landing at column 0.
+ *
+ * Useful when the editor is rendered in a horizontally-shifted view (for
+ * example a collapsed code block whose left padding is translated off
+ * screen) where columns below `minColumn` are not visible. Leave
+ * `undefined` for default arrow-key behavior.
+ */
+ minColumn?: number;
+ /**
+ * First row of the visible region. When set, `ArrowUp` on this row and
+ * `ArrowLeft` at the start of this row are blocked (no caret movement)
+ * and `onBoundary` is invoked. Useful when content above the visible
+ * region is hidden and the host wants a chance to reveal it.
+ */
+ minRow?: number;
+ /**
+ * Last row of the visible region. When set, `ArrowDown` on this row and
+ * `ArrowRight` at the end of this row are blocked (no caret movement)
+ * and `onBoundary` is invoked.
+ */
+ maxRow?: number;
+ /**
+ * Called when the user attempts to navigate past `minRow`/`maxRow` via
+ * arrow keys. When `onBoundary` is provided, the navigation is allowed
+ * to proceed natively so the host can react (e.g. expand a collapsed
+ * code block) and the caret continues moving in the now-visible
+ * content. When `onBoundary` is omitted, the navigation is blocked
+ * (caret stays put).
+ */
+ onBoundary?: () => void;
}
export interface Edit {
@@ -370,6 +414,24 @@ export const useEditable = (
});
}
+ // The visible-region bounds (`minColumn`/`minRow`/`maxRow`/`onBoundary`)
+ // only affect the keydown handler's logic, not the contentEditable setup
+ // itself. We mirror them in a ref so the keydown handler always reads the
+ // latest values, while keeping these values out of the main effect's deps.
+ // Listing them as deps would tear down and re-bind contentEditable every
+ // time they change (e.g. when a host expands a collapsed code block),
+ // which causes the browser to drop focus mid-animation.
+ const boundsRef = React.useRef({
+ minColumn: opts.minColumn,
+ minRow: opts.minRow,
+ maxRow: opts.maxRow,
+ onBoundary: opts.onBoundary,
+ });
+ boundsRef.current.minColumn = opts.minColumn;
+ boundsRef.current.minRow = opts.minRow;
+ boundsRef.current.maxRow = opts.maxRow;
+ boundsRef.current.onBoundary = opts.onBoundary;
+
// useMemo with [] is a performance hint, not a semantic guarantee — React 19
// may discard the cache and recreate the object. useState with a lazy
// initializer is the correct primitive for a referentially stable object.
@@ -693,6 +755,98 @@ export const useEditable = (
(opts!.indentation ? ' '.repeat(opts!.indentation) : '\t') +
content.slice(start);
edit.update(newContent);
+ } else if (
+ (boundsRef.current.minColumn !== undefined ||
+ boundsRef.current.minRow !== undefined ||
+ boundsRef.current.maxRow !== undefined) &&
+ !event.shiftKey &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ (event.key === 'ArrowLeft' ||
+ event.key === 'ArrowRight' ||
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowDown')
+ ) {
+ // Arrow-key navigation that respects the visible region:
+ // - `minColumn`: skip over hidden/clipped leading indent so the
+ // caret never lands before `minColumn` via horizontal navigation.
+ // - `minRow`/`maxRow`: block navigation past the visible row range
+ // and invoke `onBoundary` so the host can react (e.g. expand).
+ // Only acts on a collapsed selection — let the browser handle range
+ // expansion when a modifier is held or text is already selected.
+ const range = getCurrentRange();
+ if (range.collapsed) {
+ const { minColumn, minRow, maxRow, onBoundary } = boundsRef.current;
+ const position = getPosition(element);
+ const column = position.content.length;
+ const lines = toString(element).split('\n');
+ const lineText = lines[position.line] ?? '';
+ const lineIsIndented =
+ minColumn !== undefined &&
+ lineText.length >= minColumn &&
+ /^\s*$/.test(lineText.slice(0, minColumn));
+ const atVisibleStart = minRow !== undefined && position.line === minRow;
+ const atVisibleEnd = maxRow !== undefined && position.line === maxRow;
+ const atLineStart =
+ column === 0 || (lineIsIndented && minColumn !== undefined && column === minColumn);
+ const atLineEnd = column === lineText.length;
+
+ if (event.key === 'ArrowUp') {
+ if (atVisibleStart) {
+ if (onBoundary) {
+ // Allow native caret movement so the host can scroll the
+ // newly-revealed content into view alongside the caret.
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ }
+ } else if (event.key === 'ArrowDown') {
+ if (atVisibleEnd) {
+ if (onBoundary) {
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ }
+ } else if (event.key === 'ArrowLeft') {
+ if (atVisibleStart && atLineStart) {
+ if (onBoundary) {
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ } else if (
+ lineIsIndented &&
+ minColumn !== undefined &&
+ column === minColumn &&
+ position.line > 0
+ ) {
+ event.preventDefault();
+ const prevLine = lines[position.line - 1] ?? '';
+ edit.move({ row: position.line - 1, column: prevLine.length });
+ }
+ } else if (atVisibleEnd && atLineEnd) {
+ if (onBoundary) {
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ } else if (
+ minColumn !== undefined &&
+ column === lineText.length &&
+ position.line < lines.length - 1
+ ) {
+ const nextLine = lines[position.line + 1] ?? '';
+ const nextIsIndented =
+ nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
+ if (nextIsIndented) {
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: minColumn });
+ }
+ }
+ }
}
// After a controlled edit in plaintext-only contentEditable, the DOM is
diff --git a/packages/docs-infra/src/useCode/useFileNavigation.tsx b/packages/docs-infra/src/useCode/useFileNavigation.tsx
index 14ef2a230..4786c1a5d 100644
--- a/packages/docs-infra/src/useCode/useFileNavigation.tsx
+++ b/packages/docs-infra/src/useCode/useFileNavigation.tsx
@@ -114,6 +114,16 @@ interface UseFileNavigationProps {
* Enhancers receive the HAST root, comments extracted from source, and filename.
*/
sourceEnhancers?: SourceEnhancers;
+ /**
+ * Whether the surrounding code block is currently expanded. Forwarded to
+ * `
` so it can disable collapsed-state behaviors (e.g. `minColumn`).
+ */
+ expanded?: boolean;
+ /**
+ * Called when the user attempts to navigate the caret past the visible
+ * region of a collapsed code block. Forwarded to `
@@ -664,6 +684,8 @@ export function useFileNavigation({
language={language ?? getLanguageFromFileName(fileName)}
setSource={setSource}
shouldHighlight={shouldHighlight}
+ expanded={expanded}
+ expand={expand}
>
{source}
@@ -682,6 +704,8 @@ export function useFileNavigation({
shouldHighlight,
preClassName,
setSource,
+ expanded,
+ expand,
]);
// Create a wrapper for selectFileName that handles transformed filenames and URL updates
From 8f3570dabe45afc72ad591d9a0e4ade932d7a757 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 21:48:16 -0400
Subject: [PATCH 37/45] Improve caret behavior
---
packages/docs-infra/src/useCode/Pre.tsx | 8 +
.../src/useCode/useEditable.test.ts | 1038 +++++++++++++++++
.../docs-infra/src/useCode/useEditable.ts | 655 ++++++++++-
3 files changed, 1678 insertions(+), 23 deletions(-)
diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx
index 268ea87f6..6ce43ff91 100644
--- a/packages/docs-infra/src/useCode/Pre.tsx
+++ b/packages/docs-infra/src/useCode/Pre.tsx
@@ -244,6 +244,14 @@ export function Pre({
minRow: collapsedBounds?.minRow,
maxRow: collapsedBounds?.maxRow,
onBoundary: collapsedBounds && expand ? expand : undefined,
+ // The HAST emitted for highlighted code separates `.line` spans with
+ // whitespace text nodes (newlines) that are direct children of `.frame`.
+ // Without this, clicks or arrow navigation could land the caret in
+ // those gap nodes \u2014 visually invisible (collapsed via line-height: 0)
+ // but still real text positions in contentEditable. `.line` matches
+ // every selectable row. Only set when the highlighter has actually
+ // produced `.line` elements.
+ caretSelector: shouldHighlight ? '.line' : undefined,
});
const observer = React.useRef(null);
diff --git a/packages/docs-infra/src/useCode/useEditable.test.ts b/packages/docs-infra/src/useCode/useEditable.test.ts
index 70078a56d..2baf547ee 100644
--- a/packages/docs-infra/src/useCode/useEditable.test.ts
+++ b/packages/docs-infra/src/useCode/useEditable.test.ts
@@ -51,6 +51,7 @@ function setup(
minRow?: number;
maxRow?: number;
onBoundary?: () => void;
+ caretSelector?: string;
} = {},
) {
const element = document.createElement('pre');
@@ -1025,6 +1026,73 @@ describe('useEditable', () => {
expect(event.defaultPrevented).toBe(false);
});
+
+ it('snaps a click that lands inside the indent gutter to minColumn', () => {
+ // The user clicks at column 1 of " world" — inside the clipped
+ // 4-space gutter. The mouseup handler should jump the caret to
+ // column 4 (the visible start of the line).
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ placeSelection(element, 'hello\n '.length); // column 1 of line 1
+
+ element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
+
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ expect(pre.toString().length).toBe('hello\n '.length);
+ });
+
+ it('does not snap a click that lands at or after minColumn', () => {
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ placeSelection(element, 'hello\n wo'.length);
+
+ element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
+
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ expect(pre.toString().length).toBe('hello\n wo'.length);
+ });
+
+ it('snaps the caret to minColumn when the editor receives focus in the gutter', async () => {
+ // Tabbing into the editor lands the caret at column 0; after a frame
+ // the focus handler should jump it to minColumn.
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ placeSelection(element, 'hello\n'.length); // column 0 of line 1
+
+ element.dispatchEvent(new FocusEvent('focus'));
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve(undefined));
+ });
+
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ expect(pre.toString().length).toBe('hello\n '.length);
+ });
+
+ it('does not snap a non-collapsed selection that starts in the gutter', () => {
+ // Drag selections shouldn't be clamped mid-gesture.
+ const { element } = setup('hello\n world', { minColumn: 4 });
+ const textNode = element.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, 'hello\n '.length);
+ range.setEnd(textNode, 'hello\n wor'.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
+
+ const after = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(after.startContainer, after.startOffset);
+ expect(pre.toString().length).toBe('hello\n '.length);
+ });
});
// ---------------------------------------------------------------------------
@@ -1239,6 +1307,419 @@ describe('useEditable', () => {
});
});
+ // ---------------------------------------------------------------------------
+ // caretSelector option
+ // ---------------------------------------------------------------------------
+ describe('caretSelector option', () => {
+ /**
+ * Builds a `
` whose internal HTML mirrors the highlighted output:
+ * `.line` spans separated by literal `\n` text nodes. Returns a helper
+ * that places the collapsed selection at the given total-text offset,
+ * walking the actual `.line` text nodes (not the gap nodes) so the
+ * caret ends up *inside* a matching element.
+ */
+ function setupLined(
+ linesText: string[],
+ opts: {
+ caretSelector?: string;
+ minRow?: number;
+ maxRow?: number;
+ minColumn?: number;
+ onBoundary?: () => void;
+ } = {},
+ ) {
+ const element = document.createElement('pre');
+ linesText.forEach((text, idx) => {
+ if (idx > 0) {
+ element.appendChild(document.createTextNode('\n'));
+ }
+ const line = document.createElement('span');
+ line.className = 'line';
+ line.textContent = text;
+ element.appendChild(line);
+ });
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ const { unmount } = renderHook(
+ (props) => useEditable(props.ref, props.onChange, props.opts),
+ { initialProps: { ref, onChange, opts } },
+ );
+
+ function placeInLine(lineIndex: number, column: number) {
+ const lineSpan = element.querySelectorAll('.line')[lineIndex];
+ const textNode = lineSpan.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, column);
+ range.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
+ return { element, placeInLine, unmount };
+ }
+
+ function dispatchArrow(element: HTMLElement, key: string) {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ element.dispatchEvent(event);
+ return event;
+ }
+
+ function caretOffset(element: HTMLElement) {
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ return pre.toString().length;
+ }
+
+ it('synchronously moves caret to end of previous line on ArrowLeft at column 0', () => {
+ const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+ placeInLine(1, 0);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('hello'.length);
+ });
+
+ it('synchronously moves caret to start of next line on ArrowRight at end of line', () => {
+ const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+ placeInLine(0, 'hello'.length);
+
+ const event = dispatchArrow(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('hello\n'.length);
+ });
+
+ it('does not intercept ArrowLeft on the first line at column 0', () => {
+ const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+ placeInLine(0, 0);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept ArrowLeft mid-line', () => {
+ const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+ placeInLine(1, 1);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not intercept vertical arrows so wrapped visual lines stay native', () => {
+ // ArrowUp/ArrowDown must remain unhijacked so browsers can navigate
+ // wrapped visual lines in `pre-wrap` layouts. Gap nodes styled with
+ // `line-height: 0` are skipped vertically by the browser anyway.
+ const { element, placeInLine } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+ placeInLine(0, 2);
+
+ expect(dispatchArrow(element, 'ArrowDown').defaultPrevented).toBe(false);
+ placeInLine(1, 2);
+ expect(dispatchArrow(element, 'ArrowUp').defaultPrevented).toBe(false);
+ });
+
+ it('does nothing when caretSelector is undefined', () => {
+ const { element } = setup('hello\nworld');
+ placeSelection(element, 'hello\n'.length);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('does not wrap when the caret is not inside a matching element', () => {
+ // Plain-text editable: no `.line` spans exist, so the selector should
+ // never match and the wrap should not fire even with caretSelector set.
+ const { element } = setup('hello\nworld', { caretSelector: '.line' });
+ placeSelection(element, 'hello\n'.length);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('synchronously moves caret to next line on ArrowDown at maxRow before invoking onBoundary', () => {
+ // With `.line` spans separated by `\n` text-node gaps, native
+ // ArrowDown at the visible end would drop the caret in the gap
+ // between lines (the "between-lines" trap). The hook must move
+ // the caret onto the next `.line` *first*, then notify the host
+ // so the expansion happens with the caret already in place.
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['hello', 'world', 'tail'], {
+ caretSelector: '.line',
+ maxRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 2);
+
+ const event = dispatchArrow(element, 'ArrowDown');
+
+ expect(event.defaultPrevented).toBe(true);
+ // Caret column (2) preserved on the newly-targeted line.
+ expect(caretOffset(element)).toBe('hello\nworld\nta'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('synchronously moves caret to next line on ArrowRight at end of maxRow before invoking onBoundary', () => {
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['hello', 'world', 'tail'], {
+ caretSelector: '.line',
+ maxRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 'world'.length);
+
+ const event = dispatchArrow(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(true);
+ // Lands at column 0 of the next line, not in the inter-line gap.
+ expect(caretOffset(element)).toBe('hello\nworld\n'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('synchronously moves caret to previous line on ArrowUp at minRow before invoking onBoundary', () => {
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['head', 'hello', 'world'], {
+ caretSelector: '.line',
+ minRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 3);
+
+ const event = dispatchArrow(element, 'ArrowUp');
+
+ expect(event.defaultPrevented).toBe(true);
+ // Column 3 clamped/preserved on previous line ('head'[3] = 'd' end).
+ expect(caretOffset(element)).toBe('hea'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('synchronously moves caret to end of previous line on ArrowLeft at start of minRow before invoking onBoundary', () => {
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['head', 'hello'], {
+ caretSelector: '.line',
+ minRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 0);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('head'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('snaps caret out of an inter-line gap text node after ArrowDown (post-keydown rAF snap)', async () => {
+ // Simulate the browser's native ArrowDown behaviour landing the caret
+ // in the literal `\n` text node between `.line` spans (which happens
+ // when pressing Down on the last visible row of an expanded editable).
+ // The handler captures the source column at keydown time and the rAF
+ // snap should restore it on the destination line.
+ const { element, placeInLine } = setupLined(['abcdef', 'world'], {
+ caretSelector: '.line',
+ });
+ // Start at column 3 of "abcdef" — the column we want preserved.
+ placeInLine(0, 3);
+
+ // Dispatch ArrowDown. The handler reads the pre-move column (3)
+ // synchronously before scheduling the rAF.
+ dispatchArrow(element, 'ArrowDown');
+
+ // Now simulate the browser's native default action dropping the caret
+ // into the inter-line gap text node.
+ const gapNode = element.childNodes[1];
+ expect(gapNode.nodeType).toBe(Node.TEXT_NODE);
+ const gapRange = document.createRange();
+ gapRange.setStart(gapNode, 0);
+ gapRange.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(gapRange);
+
+ // Flush the rAF callback — the snap should run now.
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ // Caret should be inside the next `.line` AT COLUMN 3.
+ const after = window.getSelection()!.getRangeAt(0);
+ const lineEl = (
+ after.startContainer.nodeType === Node.ELEMENT_NODE
+ ? (after.startContainer as Element)
+ : after.startContainer.parentElement
+ )?.closest('.line');
+ expect(lineEl).not.toBeNull();
+ expect(caretOffset(element)).toBe('abcdef\nwor'.length);
+ });
+
+ it('snaps caret out of an inter-line gap text node after ArrowUp (post-keydown rAF snap)', async () => {
+ const { element, placeInLine } = setupLined(['abcdef', 'world'], {
+ caretSelector: '.line',
+ });
+ // Start at column 4 of "world".
+ placeInLine(1, 4);
+
+ dispatchArrow(element, 'ArrowUp');
+
+ // Simulate browser native dropping the caret in the gap.
+ const gapNode = element.childNodes[1];
+ const gapRange = document.createRange();
+ gapRange.setStart(gapNode, 1);
+ gapRange.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(gapRange);
+
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ const after = window.getSelection()!.getRangeAt(0);
+ const lineEl = (
+ after.startContainer.nodeType === Node.ELEMENT_NODE
+ ? (after.startContainer as Element)
+ : after.startContainer.parentElement
+ )?.closest('.line');
+ expect(lineEl).not.toBeNull();
+ // Snapped to column 4 of the previous line ("abcdef" → "abcd|ef").
+ expect(caretOffset(element)).toBe('abcd'.length);
+ });
+
+ it('clamps the preserved column to the destination line length on ArrowDown', async () => {
+ const { element, placeInLine } = setupLined(['abcdefghij', 'short'], {
+ caretSelector: '.line',
+ });
+ // Start at column 8 — longer than the destination line "short" (5 chars).
+ placeInLine(0, 8);
+
+ dispatchArrow(element, 'ArrowDown');
+
+ const gapNode = element.childNodes[1];
+ const gapRange = document.createRange();
+ gapRange.setStart(gapNode, 0);
+ gapRange.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(gapRange);
+
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ // Column clamped to end of "short".
+ expect(caretOffset(element)).toBe('abcdefghij\nshort'.length);
+ });
+
+ it('snaps back to the last line when ArrowDown lands past it', async () => {
+ // ArrowDown on the last visible row can drop the caret into trailing
+ // whitespace *after* the final `.line` (no next line to forward to).
+ // The snap should then go back to the last line, preserving column.
+ const { element, placeInLine } = setupLined(['hello', 'wonderful'], {
+ caretSelector: '.line',
+ });
+ placeInLine(1, 4);
+
+ dispatchArrow(element, 'ArrowDown');
+
+ // Simulate browser dropping the caret in a trailing text node past
+ // the last `.line`. Append a synthetic trailing text node to mimic
+ // what real browsers do when they overshoot.
+ const trailing = document.createTextNode('\n');
+ element.appendChild(trailing);
+ const trailingRange = document.createRange();
+ trailingRange.setStart(trailing, 0);
+ trailingRange.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(trailingRange);
+
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ const after = window.getSelection()!.getRangeAt(0);
+ const lineEl = (
+ after.startContainer.nodeType === Node.ELEMENT_NODE
+ ? (after.startContainer as Element)
+ : after.startContainer.parentElement
+ )?.closest('.line');
+ expect(lineEl).not.toBeNull();
+ // Snapped back to column 4 of the last line ("wond|erful").
+ expect(caretOffset(element)).toBe('hello\nwond'.length);
+ });
+
+ it('snaps forward to the first line when ArrowUp lands before it', async () => {
+ const { element, placeInLine } = setupLined(['hello', 'world'], {
+ caretSelector: '.line',
+ });
+ placeInLine(0, 3);
+
+ dispatchArrow(element, 'ArrowUp');
+
+ // Simulate browser dropping the caret in a synthetic leading text node.
+ const leading = document.createTextNode('\n');
+ element.insertBefore(leading, element.firstChild);
+ const leadingRange = document.createRange();
+ leadingRange.setStart(leading, 0);
+ leadingRange.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(leadingRange);
+
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ const after = window.getSelection()!.getRangeAt(0);
+ const lineEl = (
+ after.startContainer.nodeType === Node.ELEMENT_NODE
+ ? (after.startContainer as Element)
+ : after.startContainer.parentElement
+ )?.closest('.line');
+ expect(lineEl).not.toBeNull();
+ // Snapped forward to column 3 of the first line ("hel|lo").
+ expect(caretOffset(element)).toBe('\nhel'.length);
+ });
+
+ it('snaps the caret onto the next line when a click lands in an inter-line gap node', () => {
+ // Clicking between `.line` spans places the caret in the literal
+ // `\n` gap text node, which is not selectable from the user's POV.
+ // The mouseup handler should snap forward onto the next line so
+ // typing immediately works as expected.
+ const { element } = setupLined(['hello', 'world'], { caretSelector: '.line' });
+
+ // Place caret in the gap text node between lines 0 and 1.
+ const gapNode = element.childNodes[1];
+ expect(gapNode.nodeType).toBe(Node.TEXT_NODE);
+ const range = document.createRange();
+ range.setStart(gapNode, 0);
+ range.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
+
+ const after = window.getSelection()!.getRangeAt(0);
+ const lineEl = (
+ after.startContainer.nodeType === Node.ELEMENT_NODE
+ ? (after.startContainer as Element)
+ : after.startContainer.parentElement
+ )?.closest('.line');
+ expect(lineEl).not.toBeNull();
+ // Caret lands at the start of the next line ("|world").
+ expect(caretOffset(element)).toBe('hello\n'.length);
+ });
+ });
+
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
@@ -1470,6 +1951,559 @@ describe('useEditable', () => {
});
});
+ // ---------------------------------------------------------------------------
+ // Copy / Cut
+ // ---------------------------------------------------------------------------
+ describe('copy/cut', () => {
+ /**
+ * Builds a `
` mirroring the highlighter output: `display: block`
+ * `.line` spans separated by literal `\n` text node siblings. Without
+ * the copy override, copying a multi-line selection on this DOM
+ * produces duplicated newlines (one from each block element + the
+ * explicit gap text node).
+ */
+ function setupLined(linesText: string[]) {
+ const element = document.createElement('pre');
+ linesText.forEach((text, idx) => {
+ if (idx > 0) {
+ element.appendChild(document.createTextNode('\n'));
+ }
+ const line = document.createElement('span');
+ line.className = 'line';
+ // Mark as block so range.toString() still produces the canonical
+ // text — this also documents the layout being defended against.
+ line.style.display = 'block';
+ line.textContent = text;
+ element.appendChild(line);
+ });
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ const { unmount } = renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ function selectAcrossLines() {
+ const lineSpans = element.querySelectorAll('.line');
+ const startText = lineSpans[0].firstChild!;
+ const endText = lineSpans[lineSpans.length - 1].firstChild!;
+ const range = document.createRange();
+ range.setStart(startText, 0);
+ range.setEnd(endText, endText.textContent!.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
+ return { element, selectAcrossLines, onChange, unmount };
+ }
+
+ function dispatchClipboardEvent(element: HTMLElement, type: 'copy' | 'cut') {
+ const setData = vi.fn();
+ const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+ return { event, setData };
+ }
+
+ it('writes the canonical text to the clipboard on copy without duplicate newlines', () => {
+ const { element, selectAcrossLines } = setupLined(['hello', 'world']);
+ selectAcrossLines();
+
+ const { event, setData } = dispatchClipboardEvent(element, 'copy');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(setData).toHaveBeenCalledWith('text/plain', 'hello\nworld');
+ });
+
+ it('also writes the serialized HTML fragment so rich-text paste keeps highlighting', () => {
+ const { element, selectAcrossLines } = setupLined(['hello', 'world']);
+ selectAcrossLines();
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ expect(htmlCall).toBeDefined();
+ const html = htmlCall![1];
+ // Both `.line` wrappers and the literal newline gap node round-trip.
+ expect(html).toContain('class="line"');
+ expect(html).toContain('hello');
+ expect(html).toContain('world');
+ // Wrapper is a `
` so monospace + whitespace context survives.
+ expect(html.startsWith('
{
+ // Consumers scope styles by class on the editable `
`; keep
+ // that class on the clipboard wrapper so paste targets that load
+ // the same stylesheet still match.
+ const element = document.createElement('pre');
+ element.className = 'code-block hljs-language-tsx';
+ ['hello', 'world'].forEach((text, idx) => {
+ if (idx > 0) {
+ element.appendChild(document.createTextNode('\n'));
+ }
+ const lineSpan = document.createElement('span');
+ lineSpan.className = 'line';
+ lineSpan.style.display = 'block';
+ lineSpan.textContent = text;
+ element.appendChild(lineSpan);
+ });
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ const lineSpans = element.querySelectorAll('.line');
+ const startText = lineSpans[0].firstChild!;
+ const endText = lineSpans[lineSpans.length - 1].firstChild!;
+ const range = document.createRange();
+ range.setStart(startText, 0);
+ range.setEnd(endText, endText.textContent!.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ expect(html).toContain('class="code-block hljs-language-tsx"');
+ });
+
+ it('inlines the editable background color and adds rounded padding to the wrapper', () => {
+ // Paste targets that do not load the editable's stylesheet should
+ // still render with a card-like background + rounded corners that
+ // match the source visual.
+ const element = document.createElement('pre');
+ element.style.backgroundColor = 'rgb(13, 17, 23)';
+ ['hello', 'world'].forEach((text, idx) => {
+ if (idx > 0) {
+ element.appendChild(document.createTextNode('\n'));
+ }
+ const lineSpan = document.createElement('span');
+ lineSpan.className = 'line';
+ lineSpan.style.display = 'block';
+ lineSpan.textContent = text;
+ element.appendChild(lineSpan);
+ });
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ const lineSpans = element.querySelectorAll('.line');
+ const startText = lineSpans[0].firstChild!;
+ const endText = lineSpans[lineSpans.length - 1].firstChild!;
+ const range = document.createRange();
+ range.setStart(startText, 0);
+ range.setEnd(endText, endText.textContent!.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+
+ expect(html).toContain('background-color:rgb(13, 17, 23)');
+ expect(html).toContain('padding:1em');
+ expect(html).toContain('border-radius:0.5em');
+ });
+
+ it('inlines computed styles so external paste targets keep highlighting without our CSS', () => {
+ const element = document.createElement('pre');
+ const line = document.createElement('span');
+ line.className = 'line';
+ const token = document.createElement('span');
+ token.className = 'pl-k';
+ // Inline style so jsdom's getComputedStyle returns it.
+ token.style.color = 'rgb(255, 0, 0)';
+ token.style.fontWeight = 'bold';
+ token.textContent = 'const';
+ line.appendChild(token);
+ element.appendChild(line);
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ const range = document.createRange();
+ range.selectNode(token);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const setData = vi.fn();
+ const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ expect(html).toContain('color:rgb(255, 0, 0)');
+ expect(html).toContain('font-weight:bold');
+ });
+
+ it('preserves the styled wrapper when only part of a single token is selected', () => {
+ // `Range.cloneContents` returns a bare text node when the selection
+ // is entirely inside a single text node, dropping the surrounding
+ // span. Without ancestor reconstruction the partial token would
+ // serialize as `
ons
` and lose its highlight class.
+ const element = document.createElement('pre');
+ const line = document.createElement('span');
+ line.className = 'line';
+ const token = document.createElement('span');
+ token.className = 'pl-k';
+ token.style.color = 'rgb(255, 0, 0)';
+ token.style.fontWeight = 'bold';
+ token.textContent = 'consts';
+ line.appendChild(token);
+ element.appendChild(line);
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ // Select "ons" — entirely inside the token's text node.
+ const textNode = token.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, 1);
+ range.setEnd(textNode, 4);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const setData = vi.fn();
+ const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ // The wrapping token span (with its highlight class) is preserved
+ // and styled, and the partial text content sits inside it.
+ expect(html).toContain('class="pl-k"');
+ expect(html).toContain('color:rgb(255, 0, 0)');
+ expect(html).toContain('font-weight:bold');
+ expect(html).toContain('>ons<');
+ // The intermediate `.line` ancestor is also reconstructed so the
+ // block-level layout context survives.
+ expect(html).toContain('class="line"');
+ });
+
+ it('preserves the styled wrapper when the selection spans multiple children of a token', () => {
+ // Highlighted strings are typically rendered as
+ // 'react'
+ // Selecting from inside the opening quote across to inside the
+ // closing quote leaves `commonAncestorContainer` on `.pl-s`, which
+ // `Range.cloneContents` would drop — losing the outer string-token
+ // styling for every paste target.
+ const element = document.createElement('pre');
+ const line = document.createElement('span');
+ line.className = 'line';
+ const stringToken = document.createElement('span');
+ stringToken.className = 'pl-s';
+ stringToken.style.color = 'rgb(3, 47, 98)';
+ const openQuote = document.createElement('span');
+ openQuote.className = 'pl-pds';
+ openQuote.textContent = "'";
+ const middle = document.createTextNode('react');
+ const closeQuote = document.createElement('span');
+ closeQuote.className = 'pl-pds';
+ closeQuote.textContent = "'";
+ stringToken.append(openQuote, middle, closeQuote);
+ line.appendChild(stringToken);
+ element.appendChild(line);
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ // Select from inside the opening quote to inside the closing quote
+ // — the common ancestor is the `.pl-s` element.
+ const range = document.createRange();
+ range.setStart(openQuote.firstChild!, 0);
+ range.setEnd(closeQuote.firstChild!, 1);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const setData = vi.fn();
+ const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ // The outer string-token wrapper is reconstructed and styled so
+ // the middle text inherits the token-level color in paste targets.
+ expect(html).toContain('class="pl-s"');
+ expect(html).toContain('color:rgb(3, 47, 98)');
+ // The inner punctuation wrappers also survive on each side of the
+ // middle text.
+ expect(html).toContain('class="pl-pds"');
+ expect(html).toContain('react');
+ });
+
+ it('aligns style inlining when the common ancestor is the line wrapper', () => {
+ // When the selection spans multiple sibling tokens inside one
+ // `.line`, the common ancestor is `.line`. The style-inlining
+ // walks must stay aligned: the keyword token's color should land
+ // on the keyword clone, not on the reconstructed `.line` wrapper
+ // or on a later sibling.
+ const element = document.createElement('pre');
+ const line = document.createElement('span');
+ line.className = 'line';
+ line.style.display = 'block';
+ const keyword = document.createElement('span');
+ keyword.className = 'pl-k';
+ keyword.style.color = 'rgb(215, 58, 73)';
+ keyword.textContent = 'const';
+ const space = document.createTextNode(' ');
+ const ident = document.createElement('span');
+ ident.className = 'pl-c1';
+ ident.style.color = 'rgb(0, 92, 197)';
+ ident.textContent = 'foo';
+ line.append(keyword, space, ident);
+ element.appendChild(line);
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange), {
+ initialProps: { ref, onChange },
+ });
+
+ // Select from inside the keyword across the space into the ident.
+ const range = document.createRange();
+ range.setStart(keyword.firstChild!, 2);
+ range.setEnd(ident.firstChild!, 2);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const setData = vi.fn();
+ const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ // The reconstructed `.line` wrapper must NOT inherit a token color
+ // — it should only carry its own styles (display:block here).
+ const lineMatch = html.match(/]*style="([^"]*)"/);
+ expect(lineMatch).not.toBeNull();
+ expect(lineMatch![1]).not.toContain('rgb(215, 58, 73)');
+ expect(lineMatch![1]).not.toContain('rgb(0, 92, 197)');
+ // Each token clone keeps its own color on its own element.
+ expect(html).toMatch(/class="pl-k"[^>]*style="[^"]*color:rgb\(215, 58, 73\)/);
+ expect(html).toMatch(/class="pl-c1"[^>]*style="[^"]*color:rgb\(0, 92, 197\)/);
+ });
+
+ it('writes canonical text and clears the selection on cut', () => {
+ const { element, selectAcrossLines, onChange } = setupLined(['hello', 'world']);
+ selectAcrossLines();
+
+ const { event, setData } = dispatchClipboardEvent(element, 'cut');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(setData).toHaveBeenCalledWith('text/plain', 'hello\nworld');
+ // Cut should empty the selected range, leaving just the trailing \n.
+ expect(onChange).toHaveBeenCalled();
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
+ expect(lastCall[0]).toBe('\n');
+ });
+
+ it('does not intercept when the selection is collapsed', () => {
+ const { element } = setupLined(['hello', 'world']);
+ const lineSpan = element.querySelector('.line')!;
+ const range = document.createRange();
+ range.setStart(lineSpan.firstChild!, 2);
+ range.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { event, setData } = dispatchClipboardEvent(element, 'copy');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(setData).not.toHaveBeenCalled();
+ });
+
+ it('does not intercept when the selection is outside the editable', () => {
+ const { element } = setupLined(['hello', 'world']);
+ const outside = document.createElement('div');
+ outside.textContent = 'other';
+ document.body.appendChild(outside);
+ const range = document.createRange();
+ range.selectNodeContents(outside);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { event, setData } = dispatchClipboardEvent(element, 'copy');
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(setData).not.toHaveBeenCalled();
+ });
+
+ it('strips up to minColumn leading whitespace per line from text/plain', () => {
+ const { element } = setup(' hello\n world\n short', { minColumn: 4 });
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+
+ const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
+ // Lines 1-2 lose all 4 leading spaces; line 3 has only 2 to strip.
+ expect(plainCall![1]).toBe('hello\nworld\nshort');
+ });
+
+ it('strips up to minColumn leading whitespace per line from text/html', () => {
+ const element = document.createElement('pre');
+ const lineA = document.createElement('span');
+ lineA.className = 'line';
+ lineA.style.display = 'block';
+ lineA.textContent = ' hello';
+ const lineB = document.createElement('span');
+ lineB.className = 'line';
+ lineB.style.display = 'block';
+ lineB.textContent = ' world';
+ element.appendChild(lineA);
+ element.appendChild(document.createTextNode('\n'));
+ element.appendChild(lineB);
+ document.body.appendChild(element);
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
+ initialProps: { ref, onChange, opts: { minColumn: 4 } },
+ });
+
+ const range = document.createRange();
+ range.setStart(lineA.firstChild!, 0);
+ range.setEnd(lineB.firstChild!, lineB.firstChild!.textContent!.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const setData = vi.fn();
+ const event = new Event('copy', { bubbles: true, cancelable: true }) as Event & {
+ clipboardData: { setData: typeof setData };
+ };
+ event.clipboardData = { setData } as unknown as DataTransfer & { setData: typeof setData };
+ element.dispatchEvent(event);
+
+ const htmlCall = setData.mock.calls.find((call) => call[0] === 'text/html');
+ const html = htmlCall![1] as string;
+ // Leading 4-space indent removed from each `.line`'s text content.
+ expect(html).not.toContain(' hello');
+ expect(html).not.toContain(' world');
+ expect(html).toContain('hello');
+ expect(html).toContain('world');
+ });
+
+ it('only strips the remaining gutter portion when the selection starts mid-gutter', () => {
+ // 6 spaces of indent + content, minColumn=4. User selects starting
+ // from column 2 — they grabbed 2 of the 4 gutter spaces explicitly
+ // plus 2 real-indent spaces. Only the remaining 2 gutter spaces
+ // (minColumn - startColumn = 4 - 2) should be stripped, preserving
+ // the 2 real-indent spaces in the captured text.
+ const { element } = setup(' hello\n world', { minColumn: 4 });
+ const textNode = element.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, 2);
+ range.setEnd(textNode, ' hello\n world'.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+
+ const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
+ // First line: 4 captured spaces - 2 stripped = 2 spaces kept + "hello".
+ // Second line: starts at column 0 of the document, so full 4-space
+ // gutter is stripped, leaving 2 real-indent spaces + "world".
+ expect(plainCall![1]).toBe(' hello\n world');
+ });
+
+ it('strips nothing on the first line when the selection starts past the gutter', () => {
+ // minColumn=4 but selection starts at column 4 — no gutter is
+ // captured for the first line, so no stripping should occur there.
+ const { element } = setup(' hello\n world', { minColumn: 4 });
+ const textNode = element.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, 4);
+ range.setEnd(textNode, ' hello\n world'.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'copy');
+
+ const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
+ expect(plainCall![1]).toBe(' hello\n world');
+ });
+
+ it('keeps the gutter whitespace in the document when cut starts inside the gutter', () => {
+ // minColumn=4 — first 4 chars of each line are clipped indent
+ // gutter. A drag-cut starting at column 2 of line 1 must not
+ // delete the unselected/unpublished gutter chars from the
+ // document: cut should be lossless against the clipboard.
+ const { element, onChange } = setup(' hello\n world', { minColumn: 4 });
+ const textNode = element.firstChild!;
+ const range = document.createRange();
+ range.setStart(textNode, 2);
+ range.setEnd(textNode, ' hello\n world'.length);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const { setData } = dispatchClipboardEvent(element, 'cut');
+
+ // Clipboard payload omits the gutter (matches what the user saw).
+ const plainCall = setData.mock.calls.find((call) => call[0] === 'text/plain');
+ expect(plainCall![1]).toBe(' hello\n world');
+
+ // The document keeps the stripped gutter chars at the cut location:
+ // the 2 unselected leading chars + the 2 stripped gutter chars
+ // restored = 4 spaces on line 1, then \n + 4 stripped gutter
+ // spaces on line 2, then a trailing newline.
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
+ expect(lastCall[0]).toBe(' \n \n');
+ });
+ });
+
// ---------------------------------------------------------------------------
// Event listener cleanup
// ---------------------------------------------------------------------------
@@ -1486,7 +2520,11 @@ describe('useEditable', () => {
expect(windowRemove).toHaveBeenCalledWith('keydown', expect.any(Function));
expect(documentRemove).toHaveBeenCalledWith('selectstart', expect.any(Function));
expect(elementRemove).toHaveBeenCalledWith('paste', expect.any(Function));
+ expect(elementRemove).toHaveBeenCalledWith('copy', expect.any(Function));
+ expect(elementRemove).toHaveBeenCalledWith('cut', expect.any(Function));
expect(elementRemove).toHaveBeenCalledWith('keyup', expect.any(Function));
+ expect(elementRemove).toHaveBeenCalledWith('mouseup', expect.any(Function));
+ expect(elementRemove).toHaveBeenCalledWith('focus', expect.any(Function));
windowRemove.mockRestore();
documentRemove.mockRestore();
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index ac73e9e3e..f1eeb27d5 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -42,6 +42,8 @@ SOFTWARE.
// - Fix React 19 compatibility: useState lazy init for edit, useRef for MutationObserver, window SSR guard
// - Add `minColumn` option: skip clipped indent gutter via horizontal arrow navigation
// - Add `minRow`/`maxRow`/`onBoundary` options: detect arrow-key navigation past the visible region; allow native movement when `onBoundary` is provided so hosts can expand collapsed regions without losing focus
+// - Add `caretSelector` option: when the caret is inside a matching element, `ArrowLeft` at column 0 and `ArrowRight` at the end of a line jump synchronously to the adjacent line so non-selectable gap text nodes (e.g. newlines between `.line` spans) don't trap the caret. Vertical navigation is left to the browser to preserve wrapped-line behavior in `pre-wrap` layouts
+// - Override `copy`/`cut` to write `Range.toString()` for `text/plain` (avoiding duplicated newlines from block-level line wrappers like `display: block` `.line` spans separated by literal `\n` text nodes) and a `
`-wrapped clone with computed styles inlined for `text/html` so pasting into rich-text targets (email, Word, Notion, etc.) keeps syntax highlighting without depending on the host stylesheet. When `minColumn` is set, also strips up to that many leading whitespace characters per line from both payloads so the clipped indent gutter doesn't leak into the clipboard
import * as React from 'react';
@@ -81,6 +83,120 @@ const isPlaintextInputKey = (event: KeyboardEvent): boolean => {
);
};
+// Computed-style properties inlined onto each element in the copied HTML
+// fragment so external paste targets render with the same syntax
+// highlighting without needing our stylesheet.
+const CLIPBOARD_STYLE_PROPS = [
+ 'color',
+ 'background-color',
+ 'font-weight',
+ 'font-style',
+ 'text-decoration',
+];
+
+// Properties inlined onto the wrapper so the pasted block keeps the
+// editable's typography even if only a descendant was selected.
+const CLIPBOARD_ROOT_STYLE_PROPS = [
+ 'font-family',
+ 'font-size',
+ 'line-height',
+ 'white-space',
+ 'background-color',
+ 'color',
+];
+// A small amount of padding + rounded corners gives the pasted snippet a
+// card-like appearance in rich-text targets without overriding the
+// background or font that consumers already control via the editable's
+// own styles.
+const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;';
+
+// Strip leading whitespace characters per line of a plain-text string,
+// used to drop the clipped indent gutter (`minColumn`) from clipboard
+// payloads so the pasted snippet matches what the user sees.
+//
+// `firstLineCount` is the budget for the first line — typically
+// `max(0, minColumn - startColumn)` so that a selection starting
+// mid-gutter only loses the gutter portion still inside the selection.
+// `restCount` is the budget for every line after a `\n`, normally the
+// full `minColumn`.
+const stripLeadingPerLine = (text: string, firstLineCount: number, restCount: number): string => {
+ const lines = text.split('\n');
+ for (let i = 0; i < lines.length; i += 1) {
+ const budget = i === 0 ? firstLineCount : restCount;
+ if (budget <= 0) {
+ continue;
+ }
+ const line = lines[i];
+ let stripped = 0;
+ while (stripped < budget && stripped < line.length) {
+ const ch = line[stripped];
+ if (ch !== ' ' && ch !== '\t') {
+ break;
+ }
+ stripped += 1;
+ }
+ lines[i] = line.slice(stripped);
+ }
+ return lines.join('\n');
+};
+
+// Mirror of `stripLeadingPerLine` that returns *what was stripped* per
+// line, joined with `\n`. Used by `cut` to re-insert the gutter
+// whitespace at the selection location so cut is lossless: the
+// clipboard payload omits the clipped indent gutter, but the underlying
+// document keeps it.
+const extractLeadingPerLine = (text: string, firstLineCount: number, restCount: number): string => {
+ const lines = text.split('\n');
+ const prefixes: string[] = [];
+ for (let i = 0; i < lines.length; i += 1) {
+ const budget = i === 0 ? firstLineCount : restCount;
+ if (budget <= 0) {
+ prefixes.push('');
+ continue;
+ }
+ const line = lines[i];
+ let stripped = 0;
+ while (stripped < budget && stripped < line.length) {
+ const ch = line[stripped];
+ if (ch !== ' ' && ch !== '\t') {
+ break;
+ }
+ stripped += 1;
+ }
+ prefixes.push(line.slice(0, stripped));
+ }
+ return prefixes.join('\n');
+};
+
+// DOM-aware version of `stripLeadingPerLine`: walks every text node under
+// `root` in document order and removes leading whitespace at the start of
+// each logical line. The budget refills to `restCount` after every `\n`
+// and is consumed across consecutive text nodes so that indent nested
+// inside multiple wrapper spans is still removed correctly.
+const stripLeadingPerLineDom = (root: Node, firstLineCount: number, restCount: number): void => {
+ const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let atLineStart = firstLineCount > 0;
+ let remaining = firstLineCount;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? '';
+ let result = '';
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === '\n') {
+ atLineStart = restCount > 0;
+ remaining = restCount;
+ result += ch;
+ } else if (atLineStart && remaining > 0 && (ch === ' ' || ch === '\t')) {
+ remaining -= 1;
+ } else {
+ atLineStart = false;
+ result += ch;
+ }
+ }
+ node.textContent = result;
+ }
+};
+
const toString = (element: HTMLElement): string => {
const content = element.textContent || '';
@@ -370,6 +486,32 @@ export interface Options {
* (caret stays put).
*/
onBoundary?: () => void;
+ /**
+ * CSS selector identifying the elements that represent selectable
+ * "lines" inside the editable. When set, and only while the caret is
+ * actually inside an element matching the selector:
+ *
+ * - `ArrowLeft` at column 0 jumps synchronously to the end of the
+ * previous line.
+ * - `ArrowRight` at the end of a line jumps synchronously to the start
+ * of the next line.
+ *
+ * Useful when the editable contains intentionally-empty whitespace
+ * text nodes between block-level children (e.g. newline text nodes
+ * separating `.line` spans inside a `.frame`). Without this, the
+ * browser would place the caret in those gap nodes on horizontal
+ * navigation, making `ArrowLeft`/`ArrowRight` appear to no-op.
+ *
+ * Vertical navigation (`ArrowUp`/`ArrowDown`) is intentionally left to
+ * the browser so wrapped visual lines in `pre-wrap` layouts continue
+ * to behave natively. Gap nodes styled with `line-height: 0` are
+ * skipped by browsers vertically without intervention.
+ *
+ * The selector is matched against the caret's containing element via
+ * `Element.closest`, so non-`.line` render paths (e.g. plain-string
+ * editables) never trigger the wrap behavior.
+ */
+ caretSelector?: string;
}
export interface Edit {
@@ -415,8 +557,8 @@ export const useEditable = (
}
// The visible-region bounds (`minColumn`/`minRow`/`maxRow`/`onBoundary`)
- // only affect the keydown handler's logic, not the contentEditable setup
- // itself. We mirror them in a ref so the keydown handler always reads the
+ // and `caretSelector` only affect handler logic, not the contentEditable
+ // setup itself. We mirror them in a ref so the handlers always read the
// latest values, while keeping these values out of the main effect's deps.
// Listing them as deps would tear down and re-bind contentEditable every
// time they change (e.g. when a host expands a collapsed code block),
@@ -426,11 +568,13 @@ export const useEditable = (
minRow: opts.minRow,
maxRow: opts.maxRow,
onBoundary: opts.onBoundary,
+ caretSelector: opts.caretSelector,
});
boundsRef.current.minColumn = opts.minColumn;
boundsRef.current.minRow = opts.minRow;
boundsRef.current.maxRow = opts.maxRow;
boundsRef.current.onBoundary = opts.onBoundary;
+ boundsRef.current.caretSelector = opts.caretSelector;
// useMemo with [] is a performance hint, not a semantic guarantee — React 19
// may discard the cache and recreate the object. useState with a lazy
@@ -758,7 +902,8 @@ export const useEditable = (
} else if (
(boundsRef.current.minColumn !== undefined ||
boundsRef.current.minRow !== undefined ||
- boundsRef.current.maxRow !== undefined) &&
+ boundsRef.current.maxRow !== undefined ||
+ boundsRef.current.caretSelector !== undefined) &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
@@ -773,14 +918,22 @@ export const useEditable = (
// caret never lands before `minColumn` via horizontal navigation.
// - `minRow`/`maxRow`: block navigation past the visible row range
// and invoke `onBoundary` so the host can react (e.g. expand).
+ // - `caretSelector`: when set, the editable contains non-selectable
+ // gap text nodes between lines; handle horizontal line-wrap
+ // ourselves so `ArrowLeft` at column 0 lands at the end of the
+ // previous line synchronously (without flashing through the gap).
// Only acts on a collapsed selection — let the browser handle range
// expansion when a modifier is held or text is already selected.
const range = getCurrentRange();
if (range.collapsed) {
- const { minColumn, minRow, maxRow, onBoundary } = boundsRef.current;
+ const { minColumn, minRow, maxRow, onBoundary, caretSelector } = boundsRef.current;
const position = getPosition(element);
const column = position.content.length;
- const lines = toString(element).split('\n');
+ const allLines = toString(element).split('\n');
+ // `toString` guarantees a trailing `\n`, so the split produces a
+ // phantom empty entry at the end. Drop it so `lines.length - 1` is
+ // the index of the real last line.
+ const lines = allLines.length > 1 ? allLines.slice(0, -1) : allLines;
const lineText = lines[position.line] ?? '';
const lineIsIndented =
minColumn !== undefined &&
@@ -792,9 +945,54 @@ export const useEditable = (
column === 0 || (lineIsIndented && minColumn !== undefined && column === minColumn);
const atLineEnd = column === lineText.length;
+ // For caretSelector wrap, also confirm the caret is currently
+ // *inside* an element matching the selector. This keeps the wrap
+ // scoped to render paths that actually have inter-line gap nodes
+ // (e.g. highlighted `.line` spans) and leaves plain-text editables
+ // — where the browser handles arrows fine — untouched.
+ const caretInLine =
+ caretSelector !== undefined &&
+ (() => {
+ const startContainer = range.startContainer;
+ const startElement =
+ startContainer.nodeType === Node.ELEMENT_NODE
+ ? (startContainer as Element)
+ : startContainer.parentElement;
+ return !!startElement?.closest(caretSelector);
+ })();
+
+ // Helper: place the caret on a target line, clamping the column
+ // to the line's length and respecting `minColumn` indent. Used
+ // when we need to move synchronously across the inter-line gap
+ // text nodes that `caretSelector`-rendered content places between
+ // `.line` spans (a native arrow press would otherwise drop the
+ // caret *in* the gap).
+ const moveToLine = (targetRow: number, desiredColumn: number) => {
+ const targetLine = lines[targetRow] ?? '';
+ let targetColumn = Math.min(desiredColumn, targetLine.length);
+ if (
+ minColumn !== undefined &&
+ targetLine.length >= minColumn &&
+ /^\s*$/.test(targetLine.slice(0, minColumn)) &&
+ targetColumn < minColumn
+ ) {
+ targetColumn = minColumn;
+ }
+ edit.move({ row: targetRow, column: targetColumn });
+ };
+
if (event.key === 'ArrowUp') {
if (atVisibleStart) {
- if (onBoundary) {
+ if (caretInLine && position.line > 0) {
+ // Synchronously move the caret onto the previous `.line`
+ // before notifying the host. Without this, native ArrowUp
+ // can drop the caret into the inter-line gap text node
+ // (e.g. the literal `\n` between `.line` spans), trapping
+ // it in the "between lines" area after the host expands.
+ event.preventDefault();
+ moveToLine(position.line - 1, column);
+ onBoundary?.();
+ } else if (onBoundary) {
// Allow native caret movement so the host can scroll the
// newly-revealed content into view alongside the caret.
onBoundary();
@@ -804,7 +1002,11 @@ export const useEditable = (
}
} else if (event.key === 'ArrowDown') {
if (atVisibleEnd) {
- if (onBoundary) {
+ if (caretInLine && position.line < lines.length - 1) {
+ event.preventDefault();
+ moveToLine(position.line + 1, column);
+ onBoundary?.();
+ } else if (onBoundary) {
onBoundary();
} else {
event.preventDefault();
@@ -812,7 +1014,12 @@ export const useEditable = (
}
} else if (event.key === 'ArrowLeft') {
if (atVisibleStart && atLineStart) {
- if (onBoundary) {
+ if (caretInLine && position.line > 0) {
+ event.preventDefault();
+ const prevLine = lines[position.line - 1] ?? '';
+ edit.move({ row: position.line - 1, column: prevLine.length });
+ onBoundary?.();
+ } else if (onBoundary) {
onBoundary();
} else {
event.preventDefault();
@@ -826,26 +1033,82 @@ export const useEditable = (
event.preventDefault();
const prevLine = lines[position.line - 1] ?? '';
edit.move({ row: position.line - 1, column: prevLine.length });
- }
- } else if (atVisibleEnd && atLineEnd) {
- if (onBoundary) {
- onBoundary();
- } else {
+ } else if (caretInLine && column === 0 && position.line > 0) {
+ // With non-selectable gaps between lines the browser would
+ // place the caret *in* the gap text node — making ArrowLeft
+ // a no-op. Jump synchronously to the end of the previous
+ // line instead.
event.preventDefault();
+ const prevLine = lines[position.line - 1] ?? '';
+ edit.move({ row: position.line - 1, column: prevLine.length });
}
- } else if (
- minColumn !== undefined &&
- column === lineText.length &&
- position.line < lines.length - 1
- ) {
- const nextLine = lines[position.line + 1] ?? '';
- const nextIsIndented =
- nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
- if (nextIsIndented) {
+ } else if (event.key === 'ArrowRight') {
+ if (atVisibleEnd && atLineEnd) {
+ if (caretInLine && position.line < lines.length - 1) {
+ event.preventDefault();
+ moveToLine(position.line + 1, 0);
+ onBoundary?.();
+ } else if (onBoundary) {
+ onBoundary();
+ } else {
+ event.preventDefault();
+ }
+ } else if (
+ minColumn !== undefined &&
+ column === lineText.length &&
+ position.line < lines.length - 1
+ ) {
+ const nextLine = lines[position.line + 1] ?? '';
+ const nextIsIndented =
+ nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
+ if (nextIsIndented) {
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: minColumn });
+ } else if (caretInLine) {
+ // Same gap-flash avoidance as ArrowLeft: jump to start of
+ // next line synchronously.
+ event.preventDefault();
+ edit.move({ row: position.line + 1, column: 0 });
+ }
+ } else if (caretInLine && atLineEnd && position.line < lines.length - 1) {
event.preventDefault();
- edit.move({ row: position.line + 1, column: minColumn });
+ edit.move({ row: position.line + 1, column: 0 });
+ }
+ }
+ }
+
+ // Schedule a post-arrow snap when `caretSelector` is set: the
+ // browser's native arrow handling can drop the caret into the
+ // non-selectable gap text nodes (e.g. the literal `\n` between
+ // `.line` spans, especially after pressing Down on the last line
+ // or Up on the first line). After the default action runs, if the
+ // caret is no longer inside a matching element, jump it to the
+ // nearest `.line` in the direction of travel so the caret never
+ // gets stuck "between lines".
+ const { caretSelector } = boundsRef.current;
+ if (caretSelector !== undefined && !event.defaultPrevented) {
+ const direction =
+ event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 'forward' : 'backward';
+ // For vertical arrows, capture the column the user is leaving
+ // *before* the browser moves the caret, so we can land on the
+ // same column of the target line if a snap is needed. Horizontal
+ // arrows always snap to start/end of the adjacent line.
+ const isVertical = event.key === 'ArrowUp' || event.key === 'ArrowDown';
+ let preferredColumn = 0;
+ if (isVertical) {
+ const preSel = element.ownerDocument.defaultView?.getSelection();
+ if (preSel && preSel.rangeCount > 0 && preSel.isCollapsed) {
+ const preRange = preSel.getRangeAt(0);
+ if (element.contains(preRange.startContainer)) {
+ preferredColumn = getPosition(element).content.length;
+ }
}
}
+ // requestAnimationFrame fires after the browser has applied the
+ // native caret movement but before paint, so the snap is invisible.
+ window.requestAnimationFrame(() => {
+ snapCaretOutOfGapNode(direction, isVertical, preferredColumn);
+ });
}
}
@@ -919,10 +1182,352 @@ export const useEditable = (
flushChanges(true);
};
+ // When the editable wraps lines in block-level elements (e.g. `.line`
+ // spans separated by literal `\n` gap text nodes), the browser's
+ // default HTML→text/plain serializer inserts an implicit newline
+ // between each block element on top of the explicit `\n` already
+ // present in the DOM, producing duplicated newlines in the
+ // clipboard. Override copy/cut to write `Range.toString()` for
+ // `text/plain` while still preserving the HTML payload (so pasting
+ // into rich-text targets keeps syntax highlighting).
+ const onCopyOrCut = (event: ClipboardEvent) => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !event.clipboardData) {
+ return;
+ }
+ const range = selection.getRangeAt(0);
+ if (range.collapsed || !element.contains(range.commonAncestorContainer)) {
+ return;
+ }
+ event.preventDefault();
+ const minColumn = boundsRef.current.minColumn;
+ // When the selection starts mid-gutter (e.g. minColumn=4 but the
+ // user dragged from column 2), only the gutter portion *inside*
+ // the selection should be stripped from the first line. Subsequent
+ // lines always start at column 0 of the document, so they get the
+ // full `minColumn` budget.
+ let firstLineStrip = 0;
+ const restStrip = minColumn ?? 0;
+ if (minColumn !== undefined && minColumn > 0) {
+ const beforeRange = element.ownerDocument.createRange();
+ beforeRange.setStart(element, 0);
+ beforeRange.setEnd(range.startContainer, range.startOffset);
+ const beforeText = beforeRange.toString();
+ const lastNewline = beforeText.lastIndexOf('\n');
+ const startColumn = beforeText.length - (lastNewline + 1);
+ firstLineStrip = Math.max(0, minColumn - startColumn);
+ }
+ let plainText = range.toString();
+ if (restStrip > 0) {
+ // The caret-navigation guard already treats `[0, minColumn)` as a
+ // clipped indent gutter. Strip up to that many leading whitespace
+ // characters per line from the clipboard so the pasted snippet
+ // matches what the user sees rather than including indent that
+ // is hidden in the editable.
+ plainText = stripLeadingPerLine(plainText, firstLineStrip, restStrip);
+ }
+ event.clipboardData.setData('text/plain', plainText);
+ // Re-serialize the HTML ourselves since `preventDefault()` skipped
+ // the browser's default text/html write. Wrap in a `
` so the
+ // monospace + whitespace context survives, then inline the
+ // computed styles from each source element onto its clone so
+ // rich-text paste targets (email, Word, Notion, etc.) render with
+ // the same visual styling without needing our stylesheet.
+ const doc = element.ownerDocument;
+ const view = doc.defaultView;
+ const fragment = range.cloneContents();
+ const container = doc.createElement('pre');
+ // Carry the editable's className onto the wrapper so consumers
+ // that scope styles by class (e.g. `.code-block`) keep matching
+ // when the snippet is pasted into a richer environment that loads
+ // the same stylesheet.
+ if (element.className) {
+ container.className = element.className;
+ }
+ // `Range.cloneContents` returns the descendants of the
+ // `commonAncestorContainer` but never the ancestor itself, so any
+ // selection that lives entirely inside a styled wrapper (a single
+ // text node inside a token, or multiple children of the same token)
+ // loses that wrapper in the clipboard payload. The computed-style
+ // inlining pass below has nothing to inline onto in that case.
+ // Reconstruct the ancestor chain up to (but not including) the
+ // editable root and inline styles onto each rebuilt wrapper so
+ // rich-text paste targets keep the original highlighting.
+ const cac = range.commonAncestorContainer;
+ const anchor: Element | null =
+ cac.nodeType === Node.ELEMENT_NODE ? (cac as Element) : cac.parentElement;
+ let rootContent: Node = fragment;
+ // The innermost reconstructed wrapper, if any. The style-inlining
+ // pass below walks from here so the clone walker stays aligned
+ // with the source walker (which starts from the CAC's descendants).
+ let cloneStylingRoot: Node = container;
+ if (anchor && anchor !== element && element.contains(anchor)) {
+ let current: Element | null = anchor;
+ let innermost: Element | null = null;
+ while (current && current !== element) {
+ const ancestorClone = current.cloneNode(false) as Element;
+ if (view) {
+ const computed = view.getComputedStyle(current);
+ let inline = ancestorClone.getAttribute('style') ?? '';
+ for (const prop of CLIPBOARD_STYLE_PROPS) {
+ const value = computed.getPropertyValue(prop);
+ if (value && value !== 'normal' && value !== 'none' && value !== 'auto') {
+ inline += `${prop}:${value};`;
+ }
+ }
+ if (inline) {
+ ancestorClone.setAttribute('style', inline);
+ }
+ }
+ ancestorClone.appendChild(rootContent);
+ rootContent = ancestorClone;
+ if (innermost === null) {
+ innermost = ancestorClone;
+ }
+ current = current.parentElement;
+ }
+ if (innermost) {
+ cloneStylingRoot = innermost;
+ }
+ }
+ container.appendChild(rootContent);
+ if (view) {
+ // Walk the CAC's descendants and mirror them onto the cloned
+ // descendants of the innermost reconstructed wrapper. Both
+ // walkers exclude their root, so as long as the roots correspond
+ // (CAC ↔ innermost reconstructed wrapper, or CAC ↔
when
+ // there is no reconstruction) the per-step pairing is correct.
+ const sourceWalker = doc.createTreeWalker(
+ range.commonAncestorContainer,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: (node) =>
+ range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
+ },
+ );
+ const cloneWalker = doc.createTreeWalker(cloneStylingRoot, NodeFilter.SHOW_ELEMENT);
+ let source = sourceWalker.nextNode() as Element | null;
+ let clone = cloneWalker.nextNode() as Element | null;
+ while (source && clone) {
+ if (source.tagName === clone.tagName) {
+ const computed = view.getComputedStyle(source);
+ let inline = clone.getAttribute('style') ?? '';
+ for (const prop of CLIPBOARD_STYLE_PROPS) {
+ const value = computed.getPropertyValue(prop);
+ if (value && value !== 'normal' && value !== 'none' && value !== 'auto') {
+ inline += `${prop}:${value};`;
+ }
+ }
+ if (inline) {
+ clone.setAttribute('style', inline);
+ }
+ }
+ source = sourceWalker.nextNode() as Element | null;
+ clone = cloneWalker.nextNode() as Element | null;
+ }
+ // Apply the editable's own typography to the wrapper so the
+ // pasted block matches the source font/size even when only a
+ // descendant span was selected.
+ const rootComputed = view.getComputedStyle(element);
+ let rootInline = CLIPBOARD_ROOT_STATIC_STYLES;
+ for (const prop of CLIPBOARD_ROOT_STYLE_PROPS) {
+ const value = rootComputed.getPropertyValue(prop);
+ if (value) {
+ rootInline += `${prop}:${value};`;
+ }
+ }
+ if (rootInline) {
+ container.setAttribute('style', rootInline);
+ }
+ }
+ if (restStrip > 0) {
+ stripLeadingPerLineDom(container, firstLineStrip, restStrip);
+ }
+ event.clipboardData.setData('text/html', container.outerHTML);
+ if (event.type === 'cut') {
+ // Mirror the paste path: capture pre-edit state for history, then
+ // delete the selection. When `minColumn` clipped the leading
+ // gutter whitespace out of the clipboard, re-insert exactly
+ // those characters at the selection location so cut stays
+ // lossless — the document keeps the hidden indent that the user
+ // could not see and never copied.
+ state.pendingContent = trackState(true) ?? toString(element);
+ const replacement =
+ restStrip > 0 ? extractLeadingPerLine(range.toString(), firstLineStrip, restStrip) : '';
+ edit.insert(replacement);
+ flushChanges(true);
+ }
+ };
+
+ // Snap a collapsed caret out of an inter-line gap text node (e.g. the
+ // literal `\n` between `.line` spans) onto the nearest `.line` in
+ // `direction`. Used by both the post-arrow rAF and the pointer
+ // handlers — clicks can land in gap nodes too. When `isVertical`, the
+ // caret lands at `preferredColumn` of the target line (clamped);
+ // otherwise it lands at the start (forward) or end (backward).
+ // Returns `true` when a snap was applied.
+ const snapCaretOutOfGapNode = (
+ direction: 'forward' | 'backward',
+ isVertical: boolean,
+ preferredColumn: number,
+ ): boolean => {
+ const { caretSelector } = boundsRef.current;
+ if (caretSelector === undefined) {
+ return false;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return false;
+ }
+ const snapRange = sel.getRangeAt(0);
+ if (!element.contains(snapRange.startContainer)) {
+ return false;
+ }
+ const startContainer = snapRange.startContainer;
+ const startElement =
+ startContainer.nodeType === Node.ELEMENT_NODE
+ ? (startContainer as Element)
+ : startContainer.parentElement;
+ // Caret is already inside a `.line` (or equivalent) — no snap needed.
+ if (startElement?.closest(caretSelector)) {
+ return false;
+ }
+ const lineEls = Array.from(element.querySelectorAll(caretSelector));
+ if (lineEls.length === 0) {
+ return false;
+ }
+ // Use document position to pick the right neighbour.
+ let target: Element | null = null;
+ if (direction === 'forward') {
+ for (let i = 0; i < lineEls.length; i += 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp < 0 means the caret is before this line.
+ if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line ahead — caret has landed past the last line. Snap back
+ // to the last line so the caret stays inside an editable row.
+ if (!target) {
+ target = lineEls[lineEls.length - 1];
+ }
+ } else {
+ for (let i = lineEls.length - 1; i >= 0; i -= 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp > 0 means the caret is after this line.
+ if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line behind — caret has landed before the first line.
+ if (!target) {
+ target = lineEls[0];
+ }
+ }
+ if (!target) {
+ return false;
+ }
+ const newRange = element.ownerDocument.createRange();
+ if (isVertical) {
+ // Walk the target line's text nodes to find the offset that
+ // matches `preferredColumn`, clamping to the line length.
+ const targetText = target.textContent ?? '';
+ const targetColumn = Math.min(preferredColumn, targetText.length);
+ let remaining = targetColumn;
+ const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
+ let placed = false;
+ let node = walker.nextNode();
+ while (node) {
+ const len = node.textContent?.length ?? 0;
+ if (remaining <= len) {
+ newRange.setStart(node, remaining);
+ newRange.collapse(true);
+ placed = true;
+ break;
+ }
+ remaining -= len;
+ node = walker.nextNode();
+ }
+ if (!placed) {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ } else if (direction === 'forward') {
+ newRange.selectNodeContents(target);
+ newRange.collapse(true);
+ } else {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ sel.removeAllRanges();
+ sel.addRange(newRange);
+ return true;
+ };
+
+ // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
+ // when the user clicks there. The arrow-key handler already prevents
+ // landing inside the gutter via keyboard navigation; this covers
+ // pointer-driven clicks. Range selections are left alone — clamping the
+ // anchor of a drag would feel surprising mid-gesture.
+ const snapCaretOutOfGutter = () => {
+ const { minColumn } = boundsRef.current;
+ if (minColumn === undefined || minColumn <= 0) {
+ return;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return;
+ }
+ const range = sel.getRangeAt(0);
+ if (!element.contains(range.startContainer)) {
+ return;
+ }
+ const position = getPosition(element);
+ if (position.content.length >= minColumn) {
+ return;
+ }
+ // Only snap when the gutter is actually whitespace — otherwise the
+ // line is shorter than `minColumn` and there's nowhere to snap to.
+ const lineText = toString(element).split('\n')[position.line] ?? '';
+ if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
+ return;
+ }
+ edit.move({ row: position.line, column: minColumn });
+ };
+
+ const onMouseUp = () => {
+ // First lift the caret out of any inter-line gap node so the
+ // gutter check below can see a real line position.
+ snapCaretOutOfGapNode('forward', false, 0);
+ snapCaretOutOfGutter();
+ };
+
+ // Tabbing into the editor places the caret at column 0 of the first
+ // line, which lands inside the clipped indent gutter. Browsers set the
+ // initial selection asynchronously after `focus`, so defer the snap.
+ const onFocus = () => {
+ const view = element.ownerDocument.defaultView;
+ if (!view) {
+ return;
+ }
+ view.requestAnimationFrame(() => {
+ snapCaretOutOfGapNode('forward', false, 0);
+ snapCaretOutOfGutter();
+ });
+ };
+
document.addEventListener('selectstart', onSelect);
window.addEventListener('keydown', onKeyDown);
element.addEventListener('paste', onPaste);
+ element.addEventListener('copy', onCopyOrCut);
+ element.addEventListener('cut', onCopyOrCut);
element.addEventListener('keyup', onKeyUp);
+ element.addEventListener('mouseup', onMouseUp);
+ element.addEventListener('focus', onFocus);
return () => {
if (state.repeatFlushId !== null) {
@@ -932,7 +1537,11 @@ export const useEditable = (
document.removeEventListener('selectstart', onSelect);
window.removeEventListener('keydown', onKeyDown);
element.removeEventListener('paste', onPaste);
+ element.removeEventListener('copy', onCopyOrCut);
+ element.removeEventListener('cut', onCopyOrCut);
element.removeEventListener('keyup', onKeyUp);
+ element.removeEventListener('mouseup', onMouseUp);
+ element.removeEventListener('focus', onFocus);
element.style.whiteSpace = prevWhiteSpace;
element.contentEditable = prevContentEditable;
};
From ff387f9a533256a8713df46f0b7b0cb63c7cf270 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 21:48:23 -0400
Subject: [PATCH 38/45] Prettier
---
docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
index c7947bb83..7e6202967 100644
--- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
+++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
@@ -395,7 +395,7 @@ const sourceEnhancers = [
| `paddingFrameMaxSize` | number | `0` | Maximum number of context lines above/below the focused region |
| `focusFramesMaxSize` | number | — | Maximum total lines in the focus area (padding + region). When the region fits, remaining budget is split floor/ceil between padding-top and padding-bottom. When the region exceeds this limit, a focused window is taken from the start of the region, and the remaining overflow lines are marked as unfocused. |
| `strictHighlightText` | boolean | `false` | When `true`, throws an error if a `@highlight-text` match has to be fragmented across element boundaries (producing `data-hl-part` spans). Highlights that wrap multiple complete elements in a single `data-hl` span are still allowed. |
-| `emitFrameIndent` | boolean | `false` | When `true`, region frames (`highlighted`, `focus`, and their unfocused variants) receive a `data-frame-indent` attribute carrying the shared indent level of the highlighted lines. Useful for CSS-driven indent shifting in collapsed views (see [Indent Shifting](#indent-shifting)). |
+| `emitFrameIndent` | boolean | `false` | When `true`, region frames (`highlighted`, `focus`, and their unfocused variants) receive a `data-frame-indent` attribute carrying the shared indent level of the highlighted lines. Useful for CSS-driven indent shifting in collapsed views (see [Indent Shifting](#indent-shifting)). |
When `paddingFrameMaxSize` is `0` (the default), no padding frames are created and only region frames (`highlighted`, `focus`, etc.) and normal frames are produced.
From a0f3663ed3dec3d64ab9b2da5dd545a6acbe8789 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Mon, 27 Apr 2026 23:16:59 -0400
Subject: [PATCH 39/45] Fix lint
Co-authored-by: Copilot
---
.../docs-infra/src/useCode/useEditable.ts | 280 +++++++++---------
1 file changed, 140 insertions(+), 140 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index f1eeb27d5..536da17b1 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -803,6 +803,146 @@ export const useEditable = (
state.pendingContent = null;
};
+ // Snap a collapsed caret out of an inter-line gap text node (e.g. the
+ // literal `\n` between `.line` spans) onto the nearest `.line` in
+ // `direction`. Used by both the post-arrow rAF and the pointer
+ // handlers — clicks can land in gap nodes too. When `isVertical`, the
+ // caret lands at `preferredColumn` of the target line (clamped);
+ // otherwise it lands at the start (forward) or end (backward).
+ // Returns `true` when a snap was applied.
+ const snapCaretOutOfGapNode = (
+ direction: 'forward' | 'backward',
+ isVertical: boolean,
+ preferredColumn: number,
+ ): boolean => {
+ const { caretSelector } = boundsRef.current;
+ if (caretSelector === undefined) {
+ return false;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return false;
+ }
+ const snapRange = sel.getRangeAt(0);
+ if (!element.contains(snapRange.startContainer)) {
+ return false;
+ }
+ const startContainer = snapRange.startContainer;
+ const startElement =
+ startContainer.nodeType === Node.ELEMENT_NODE
+ ? (startContainer as Element)
+ : startContainer.parentElement;
+ // Caret is already inside a `.line` (or equivalent) — no snap needed.
+ if (startElement?.closest(caretSelector)) {
+ return false;
+ }
+ const lineEls = Array.from(element.querySelectorAll(caretSelector));
+ if (lineEls.length === 0) {
+ return false;
+ }
+ // Use document position to pick the right neighbour.
+ let target: Element | null = null;
+ if (direction === 'forward') {
+ for (let i = 0; i < lineEls.length; i += 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp < 0 means the caret is before this line.
+ if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line ahead — caret has landed past the last line. Snap back
+ // to the last line so the caret stays inside an editable row.
+ if (!target) {
+ target = lineEls[lineEls.length - 1];
+ }
+ } else {
+ for (let i = lineEls.length - 1; i >= 0; i -= 1) {
+ const r = element.ownerDocument.createRange();
+ r.selectNode(lineEls[i]);
+ // cmp > 0 means the caret is after this line.
+ if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
+ target = lineEls[i];
+ break;
+ }
+ }
+ // No line behind — caret has landed before the first line.
+ if (!target) {
+ target = lineEls[0];
+ }
+ }
+ if (!target) {
+ return false;
+ }
+ const newRange = element.ownerDocument.createRange();
+ if (isVertical) {
+ // Walk the target line's text nodes to find the offset that
+ // matches `preferredColumn`, clamping to the line length.
+ const targetText = target.textContent ?? '';
+ const targetColumn = Math.min(preferredColumn, targetText.length);
+ let remaining = targetColumn;
+ const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
+ let placed = false;
+ let node = walker.nextNode();
+ while (node) {
+ const len = node.textContent?.length ?? 0;
+ if (remaining <= len) {
+ newRange.setStart(node, remaining);
+ newRange.collapse(true);
+ placed = true;
+ break;
+ }
+ remaining -= len;
+ node = walker.nextNode();
+ }
+ if (!placed) {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ } else if (direction === 'forward') {
+ newRange.selectNodeContents(target);
+ newRange.collapse(true);
+ } else {
+ newRange.selectNodeContents(target);
+ newRange.collapse(false);
+ }
+ sel.removeAllRanges();
+ sel.addRange(newRange);
+ return true;
+ };
+
+ // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
+ // when the user clicks there. The arrow-key handler already prevents
+ // landing inside the gutter via keyboard navigation; this covers
+ // pointer-driven clicks. Range selections are left alone — clamping the
+ // anchor of a drag would feel surprising mid-gesture.
+ const snapCaretOutOfGutter = () => {
+ const { minColumn } = boundsRef.current;
+ if (minColumn === undefined || minColumn <= 0) {
+ return;
+ }
+ const sel = element.ownerDocument.defaultView?.getSelection();
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
+ return;
+ }
+ const range = sel.getRangeAt(0);
+ if (!element.contains(range.startContainer)) {
+ return;
+ }
+ const position = getPosition(element);
+ if (position.content.length >= minColumn) {
+ return;
+ }
+ // Only snap when the gutter is actually whitespace — otherwise the
+ // line is shorter than `minColumn` and there's nowhere to snap to.
+ const lineText = toString(element).split('\n')[position.line] ?? '';
+ if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
+ return;
+ }
+ edit.move({ row: position.line, column: minColumn });
+ };
+
const onKeyDown = (event: HTMLElementEventMap['keydown']) => {
if (event.defaultPrevented || event.target !== element) {
return;
@@ -1359,146 +1499,6 @@ export const useEditable = (
}
};
- // Snap a collapsed caret out of an inter-line gap text node (e.g. the
- // literal `\n` between `.line` spans) onto the nearest `.line` in
- // `direction`. Used by both the post-arrow rAF and the pointer
- // handlers — clicks can land in gap nodes too. When `isVertical`, the
- // caret lands at `preferredColumn` of the target line (clamped);
- // otherwise it lands at the start (forward) or end (backward).
- // Returns `true` when a snap was applied.
- const snapCaretOutOfGapNode = (
- direction: 'forward' | 'backward',
- isVertical: boolean,
- preferredColumn: number,
- ): boolean => {
- const { caretSelector } = boundsRef.current;
- if (caretSelector === undefined) {
- return false;
- }
- const sel = element.ownerDocument.defaultView?.getSelection();
- if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
- return false;
- }
- const snapRange = sel.getRangeAt(0);
- if (!element.contains(snapRange.startContainer)) {
- return false;
- }
- const startContainer = snapRange.startContainer;
- const startElement =
- startContainer.nodeType === Node.ELEMENT_NODE
- ? (startContainer as Element)
- : startContainer.parentElement;
- // Caret is already inside a `.line` (or equivalent) — no snap needed.
- if (startElement?.closest(caretSelector)) {
- return false;
- }
- const lineEls = Array.from(element.querySelectorAll(caretSelector));
- if (lineEls.length === 0) {
- return false;
- }
- // Use document position to pick the right neighbour.
- let target: Element | null = null;
- if (direction === 'forward') {
- for (let i = 0; i < lineEls.length; i += 1) {
- const r = element.ownerDocument.createRange();
- r.selectNode(lineEls[i]);
- // cmp < 0 means the caret is before this line.
- if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
- target = lineEls[i];
- break;
- }
- }
- // No line ahead — caret has landed past the last line. Snap back
- // to the last line so the caret stays inside an editable row.
- if (!target) {
- target = lineEls[lineEls.length - 1];
- }
- } else {
- for (let i = lineEls.length - 1; i >= 0; i -= 1) {
- const r = element.ownerDocument.createRange();
- r.selectNode(lineEls[i]);
- // cmp > 0 means the caret is after this line.
- if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
- target = lineEls[i];
- break;
- }
- }
- // No line behind — caret has landed before the first line.
- if (!target) {
- target = lineEls[0];
- }
- }
- if (!target) {
- return false;
- }
- const newRange = element.ownerDocument.createRange();
- if (isVertical) {
- // Walk the target line's text nodes to find the offset that
- // matches `preferredColumn`, clamping to the line length.
- const targetText = target.textContent ?? '';
- const targetColumn = Math.min(preferredColumn, targetText.length);
- let remaining = targetColumn;
- const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
- let placed = false;
- let node = walker.nextNode();
- while (node) {
- const len = node.textContent?.length ?? 0;
- if (remaining <= len) {
- newRange.setStart(node, remaining);
- newRange.collapse(true);
- placed = true;
- break;
- }
- remaining -= len;
- node = walker.nextNode();
- }
- if (!placed) {
- newRange.selectNodeContents(target);
- newRange.collapse(false);
- }
- } else if (direction === 'forward') {
- newRange.selectNodeContents(target);
- newRange.collapse(true);
- } else {
- newRange.selectNodeContents(target);
- newRange.collapse(false);
- }
- sel.removeAllRanges();
- sel.addRange(newRange);
- return true;
- };
-
- // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
- // when the user clicks there. The arrow-key handler already prevents
- // landing inside the gutter via keyboard navigation; this covers
- // pointer-driven clicks. Range selections are left alone — clamping the
- // anchor of a drag would feel surprising mid-gesture.
- const snapCaretOutOfGutter = () => {
- const { minColumn } = boundsRef.current;
- if (minColumn === undefined || minColumn <= 0) {
- return;
- }
- const sel = element.ownerDocument.defaultView?.getSelection();
- if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
- return;
- }
- const range = sel.getRangeAt(0);
- if (!element.contains(range.startContainer)) {
- return;
- }
- const position = getPosition(element);
- if (position.content.length >= minColumn) {
- return;
- }
- // Only snap when the gutter is actually whitespace — otherwise the
- // line is shorter than `minColumn` and there's nowhere to snap to.
- const lineText = toString(element).split('\n')[position.line] ?? '';
- if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
- return;
- }
- edit.move({ row: position.line, column: minColumn });
- };
-
const onMouseUp = () => {
// First lift the caret out of any inter-line gap node so the
// gutter check below can see a real line position.
From d36d97a0bd5f1fe2571c565f126e944841c16577 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 00:30:10 -0400
Subject: [PATCH 40/45] Fix cursor edge case
Co-authored-by: Copilot
---
.../src/useCode/useEditable.browser.ts | 215 ++++++++++++++++++
.../docs-infra/src/useCode/useEditable.ts | 101 +++++++-
2 files changed, 305 insertions(+), 11 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.browser.ts b/packages/docs-infra/src/useCode/useEditable.browser.ts
index 50689ac40..f95357010 100644
--- a/packages/docs-infra/src/useCode/useEditable.browser.ts
+++ b/packages/docs-infra/src/useCode/useEditable.browser.ts
@@ -1315,3 +1315,218 @@ describe('useEditable – newline preservation', () => {
expect(ln).toBeGreaterThanOrEqual(2);
});
});
+
+// ---------------------------------------------------------------------------
+// Disconnected-window arrow regression (no boundary involved)
+// ---------------------------------------------------------------------------
+describe('useEditable – arrow keys during the disconnected window', () => {
+ it('a plain ArrowDown right after Enter (no onBoundary) is not reverted by the post-flush rerender', async () => {
+ // No `minRow`/`maxRow`/`onBoundary` here — just plain editing. The
+ // race we care about: Enter → flushChanges() disconnects, ArrowDown
+ // fires while `state.disconnected` is still true, the fast-path
+ // calls `unblock([])` to nudge React, and the resulting layout-
+ // effect must NOT snap the caret back to the pre-arrow line.
+ const element = document.createElement('pre');
+ element.contentEditable = 'plaintext-only';
+ element.style.whiteSpace = 'pre-wrap';
+ document.body.appendChild(element);
+ element.textContent = 'line1\nline2\nline3\n';
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>();
+ const { unmount } = renderHook((props) => useEditable(props.ref, props.onChange, props.opts), {
+ initialProps: { ref, onChange, opts: {} as { indentation?: number } },
+ });
+
+ try {
+ // Place the caret at the end of line 1.
+ placeCaret(element, 'line1'.length);
+ await userEvent.keyboard('{Enter}');
+ // After Enter the caret is at the start of line 2 (a blank line
+ // between line1 and line2 — Enter splits the text).
+ await userEvent.keyboard('{ArrowDown}');
+
+ // Walk to caret to compute the visual line.
+ const sel = window.getSelection()!;
+ const range = sel.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ const globalOffset = pre.toString().length;
+ const fullText = element.textContent ?? '';
+ const computedLine = fullText.slice(0, globalOffset).split('\n').length - 1;
+
+ // After Enter the caret is at the start of the new blank line
+ // (row 1) between `line1` and `line2`. A correctly-handled
+ // ArrowDown moves the caret down exactly one visual line, landing
+ // at row 2 (`line2`). Asserting the exact target row catches both
+ // the original snap-back regression (caret rebounds to row 0) and
+ // any accidental overshoot (caret skips past row 2).
+ expect(computedLine).toBe(2);
+ } finally {
+ unmount();
+ element.remove();
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Focus-frame ArrowUp regression
+// ---------------------------------------------------------------------------
+describe('useEditable – focus frame ArrowUp after Enter', () => {
+ /**
+ * Render `text` as a flat list of `.line` spans separated by literal `\n`
+ * text-node gaps — the same shape the production highlighter emits when the
+ * editable is mounted.
+ */
+ function renderLines(element: HTMLElement, text: string) {
+ const lines = text.split('\n');
+ // The hook's `toString()` adds a trailing `\n` if missing — match it.
+ const visibleLines = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
+ element.replaceChildren();
+ visibleLines.forEach((lineText, idx) => {
+ if (idx > 0) {
+ element.appendChild(document.createTextNode('\n'));
+ }
+ const line = document.createElement('span');
+ line.className = 'line';
+ line.setAttribute('data-ln', String(idx + 1));
+ line.textContent = lineText;
+ element.appendChild(line);
+ });
+ }
+
+ // Firefox computes the default-action target of arrow keys from the
+ // selection at the time the keydown was queued, so when we restore the
+ // caret inside the keydown handler the native ArrowUp doesn't see it.
+ // The user-visible bug below still requires fixing for Chromium/WebKit
+ // (where the vast majority of editors run) — Firefox needs a separate
+ // workaround that's tracked outside this regression test.
+ const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent);
+
+ it.skipIf(isFirefox)(
+ 'reproduces: Enter at end of a focus-frame line then ArrowUp twice should land outside the frame',
+ async () => {
+ // Mirrors the user's example:
+ // import * as React from 'react';
+ // import { Checkbox } from '@/components/Checkbox';
+ //
+ // export default function CheckboxBasic() {
+ // return (
+ //
+ // ← focus frame line 7
+ //
... ← focus frame line 8
+ //
+ // );
+ // }
+ const initialText = [
+ "import * as React from 'react';",
+ "import { Checkbox } from '@/components/Checkbox';",
+ '',
+ 'export default function CheckboxBasic() {',
+ ' return (',
+ '
',
+ ' ',
+ "
Type Whatever You Want Below
",
+ '
',
+ ' );',
+ '}',
+ ].join('\n');
+
+ const element = document.createElement('pre');
+ element.contentEditable = 'plaintext-only';
+ element.style.whiteSpace = 'pre-wrap';
+ element.style.tabSize = '2';
+ document.body.appendChild(element);
+ renderLines(element, initialText);
+
+ // The host's onBoundary in production is `expand`, which calls setState
+ // and triggers a React re-render that drops the collapsed bounds.
+ // Reproduce that here so the unconditional layout effect inside
+ // `useEditable` re-runs mid-arrow-keypress, which is what was snapping
+ // the caret back to the pre-ArrowUp `state.position`.
+ let expanded = false;
+ let triggerRerender = () => {};
+ const onBoundary = vi.fn(() => {
+ expanded = true;
+ triggerRerender();
+ });
+
+ const collapsedOpts = {
+ indentation: 2,
+ minColumn: 6,
+ minRow: 6,
+ maxRow: 7,
+ onBoundary,
+ caretSelector: '.line',
+ };
+ const expandedOpts = {
+ indentation: 2,
+ onBoundary,
+ caretSelector: '.line',
+ };
+
+ const ref = { current: element };
+ const onChange = vi.fn<(text: string, position: Position) => void>((newText: string) => {
+ // Simulate the host re-render: replay a fresh `.line` DOM structure
+ // for the new content. This is what `Pre.tsx` does via React when
+ // `setSource` is called from inside `useEditable`.
+ renderLines(element, newText);
+ });
+
+ const { unmount, rerender } = renderHook(
+ (props) => useEditable(props.ref, props.onChange, props.opts),
+ { initialProps: { ref, onChange, opts: collapsedOpts as typeof expandedOpts } },
+ );
+ triggerRerender = () => {
+ rerender({ ref, onChange, opts: expanded ? expandedOpts : collapsedOpts });
+ };
+
+ try {
+ element.focus();
+ // Place caret at the END of line 7 (``).
+ const line7Text = ' ';
+ const offset = [
+ "import * as React from 'react';",
+ "import { Checkbox } from '@/components/Checkbox';",
+ '',
+ 'export default function CheckboxBasic() {',
+ ' return (',
+ '
',
+ line7Text,
+ ].join('\n').length;
+ placeCaret(element, offset);
+
+ // Press Enter, then ArrowUp twice — exactly the user's repro.
+ await userEvent.keyboard('{Enter}');
+ await userEvent.keyboard('{ArrowUp}');
+ await userEvent.keyboard('{ArrowUp}');
+
+ // Inspect where the caret ended up.
+ const sel = window.getSelection()!;
+ const range = sel.getRangeAt(0);
+
+ // Build a global offset to derive the visual line/column of the caret.
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ const globalOffset = pre.toString().length;
+ const fullText = element.textContent ?? '';
+ const linesUpTo = fullText.slice(0, globalOffset).split('\n');
+ const computedLine = linesUpTo.length - 1;
+
+ // After Enter (caret on new blank line 8), ArrowUp #1 should move the
+ // caret to line 7 (``, 0-indexed row 6 =
+ // minRow). ArrowUp #2 from minRow should escape upward to row 5
+ // (`
`) and invoke `onBoundary` to expand the host. Without
+ // the fix, the first ArrowUp gets eaten by the `state.disconnected`
+ // branch and the user only navigates one line instead of two.
+ expect(computedLine).toBe(5);
+ expect(onBoundary).toHaveBeenCalled();
+ } finally {
+ unmount();
+ element.remove();
+ }
+ },
+ );
+});
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 536da17b1..8bd16275c 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -439,6 +439,17 @@ interface State {
position: Position | null;
/** setTimeout id used to debounce flushChanges() calls during key-repeat */
repeatFlushId: ReturnType | null;
+ /**
+ * Set when an arrow-key handler invokes `onBoundary` (which typically
+ * triggers a host re-render to expand a collapsed region). The native
+ * arrow-key default action moves the caret AFTER our keydown handler
+ * returns, but the host's re-render commits BEFORE the resulting
+ * `selectionchange` updates `state.position`. Without this flag, the
+ * unconditional restore effect would snap the caret back to the stale
+ * pre-arrow `state.position` on that intermediate render. The flag is
+ * cleared after one skipped restore.
+ */
+ skipNextRestore: boolean;
}
export interface Options {
@@ -544,6 +555,7 @@ export const useEditable = (
historyAt: -1,
position: null,
repeatFlushId: null,
+ skipNextRestore: false,
}))[0];
// MutationObserver is created once via useRef so it is never recreated on
@@ -659,7 +671,14 @@ export const useEditable = (
// here would jump the cursor back on every incidental re-render (e.g.
// from an async enhancer setState). edit.insert() already placed the
// cursor correctly in the DOM — leave it there until the debounce fires.
- if (state.position && state.repeatFlushId === null) {
+ //
+ // Also skip on the render right after an arrow-key boundary callback
+ // (see `state.skipNextRestore`): the native arrow movement hasn't
+ // applied yet, so `state.position` is the pre-arrow location and
+ // restoring it would visibly snap the caret back upward/downward.
+ if (state.skipNextRestore) {
+ state.skipNextRestore = false;
+ } else if (state.position && state.repeatFlushId === null) {
const { position, extent } = state.position;
const cursorRange = makeRange(elementRef.current, position, position + extent);
adjustCursorAtNewlineBoundary(cursorRange);
@@ -948,13 +967,57 @@ export const useEditable = (
return;
}
if (state.disconnected) {
- // React Quirk: It's expected that we may lose events while disconnected, which is why
- // we'd like to block some inputs if they're unusually fast. However, this always
- // coincides with React not executing the update immediately and then getting stuck,
- // which can be prevented by issuing a dummy state change.
- event.preventDefault();
+ // React Quirk: between flushChanges() (which calls disconnect() and
+ // rewinds the DOM back to the pre-edit content) and React's commit
+ // (which re-observes via useLayoutEffect and restores state.position),
+ // an event can fire that we'd otherwise mishandle.
+ //
+ // For NAVIGATION keys (arrows) the DOM revert is irrelevant — the
+ // browser only needs a valid caret position to compute the next
+ // selection — so resync inline (restore caret + re-observe) and let
+ // the event proceed. Otherwise the keystroke would be eaten and the
+ // user would lose, for example, an ArrowUp step after Enter inside
+ // a focus frame. We deliberately do NOT include Home/End/PageUp/
+ // PageDown here: they would also need to compensate for the pending
+ // rerender (matching the arrow-key skip-next-restore handling) and
+ // currently lack that coverage, so keep them on the safe path.
+ //
+ // For EDITING keys (printable text, Enter, Tab, Backspace, Delete,
+ // …) we must NOT fall through: the live DOM is the reverted
+ // pre-edit snapshot, so applying a second edit on top would target
+ // the wrong text and corrupt content. Keep the original block-and-
+ // unblock behavior for those keys — React will commit the queued
+ // onChange momentarily and the user can re-issue the keystroke.
+ const isArrowKey =
+ event.key === 'ArrowLeft' ||
+ event.key === 'ArrowRight' ||
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowDown';
+ if (!isArrowKey) {
+ event.preventDefault();
+ unblock([]);
+ return;
+ }
+ if (state.position && state.repeatFlushId === null) {
+ const { position, extent } = state.position;
+ const cursorRange = makeRange(element, position, position + extent);
+ adjustCursorAtNewlineBoundary(cursorRange);
+ setCurrentRange(cursorRange);
+ }
+ observerRef.current?.observe(element, observerSettings);
+ state.disconnected = false;
+ // The `unblock([])` below schedules a React rerender. If that
+ // rerender's restore effect runs before the native arrow movement
+ // has updated `state.position` (which happens asynchronously via
+ // `selectionchange`), the restore would snap the caret back to the
+ // stale pre-arrow position. In practice `selectionchange` usually
+ // fires first so the restore is a no-op, but arming the skip flag
+ // makes the fast path race-free regardless of scheduling. The
+ // boundary-movement branches arm the same flag for the same reason.
+ state.skipNextRestore = true;
unblock([]);
- return;
+ // Fall through and let this arrow event be handled normally
+ // with the restored caret position.
}
if (isUndoRedoKey(event)) {
@@ -1131,10 +1194,14 @@ export const useEditable = (
// it in the "between lines" area after the host expands.
event.preventDefault();
moveToLine(position.line - 1, column);
- onBoundary?.();
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
} else if (onBoundary) {
// Allow native caret movement so the host can scroll the
// newly-revealed content into view alongside the caret.
+ state.skipNextRestore = true;
onBoundary();
} else {
event.preventDefault();
@@ -1145,8 +1212,12 @@ export const useEditable = (
if (caretInLine && position.line < lines.length - 1) {
event.preventDefault();
moveToLine(position.line + 1, column);
- onBoundary?.();
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
} else if (onBoundary) {
+ state.skipNextRestore = true;
onBoundary();
} else {
event.preventDefault();
@@ -1158,8 +1229,12 @@ export const useEditable = (
event.preventDefault();
const prevLine = lines[position.line - 1] ?? '';
edit.move({ row: position.line - 1, column: prevLine.length });
- onBoundary?.();
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
} else if (onBoundary) {
+ state.skipNextRestore = true;
onBoundary();
} else {
event.preventDefault();
@@ -1187,8 +1262,12 @@ export const useEditable = (
if (caretInLine && position.line < lines.length - 1) {
event.preventDefault();
moveToLine(position.line + 1, 0);
- onBoundary?.();
+ if (onBoundary) {
+ state.skipNextRestore = true;
+ onBoundary();
+ }
} else if (onBoundary) {
+ state.skipNextRestore = true;
onBoundary();
} else {
event.preventDefault();
From 33be548fd5f4044b65c3863d0e2e3b52c264651a Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 08:54:54 -0400
Subject: [PATCH 41/45] Fix backspace in collapsed
---
.../src/useCode/useEditable.test.ts | 71 +++++++++++++++++++
.../docs-infra/src/useCode/useEditable.ts | 29 +++++++-
2 files changed, 98 insertions(+), 2 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.test.ts b/packages/docs-infra/src/useCode/useEditable.test.ts
index 2baf547ee..ebbea985e 100644
--- a/packages/docs-infra/src/useCode/useEditable.test.ts
+++ b/packages/docs-infra/src/useCode/useEditable.test.ts
@@ -1093,6 +1093,77 @@ describe('useEditable', () => {
pre.setEnd(after.startContainer, after.startOffset);
expect(pre.toString().length).toBe('hello\n '.length);
});
+
+ it('Backspace at minColumn on a blank indented line collapses the line and lands the caret on the previous line', () => {
+ // Three lines: `hello`, a blank line of exactly minColumn (4)
+ // whitespace characters, and `world`. With the caret at the end of
+ // the blank line (column = minColumn), Backspace would normally
+ // delete one indent space and leave the caret in the clipped
+ // `[0, minColumn)` gutter. Instead we collapse the entire blank
+ // line so the caret lands at the end of `hello`.
+ const { element, onChange } = setup('hello\n \n world', {
+ minColumn: 4,
+ indentation: 2,
+ });
+ placeSelection(element, 'hello\n '.length);
+
+ const keyDown = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ code: 'Backspace',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(keyDown);
+
+ expect(keyDown.defaultPrevented).toBe(true);
+ expect(element.textContent).toBe('hello\n world');
+ const range = window.getSelection()!.getRangeAt(0);
+ const pre = document.createRange();
+ pre.setStart(element, 0);
+ pre.setEnd(range.startContainer, range.startOffset);
+ expect(pre.toString().length).toBe('hello'.length);
+
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Backspace', bubbles: true }));
+ const [text] = onChange.mock.calls[onChange.mock.calls.length - 1];
+ expect(text).toBe('hello\n world\n');
+ });
+
+ it('Backspace at minColumn on a non-blank indented line falls through to a single-character delete', () => {
+ // The current line has more content past `minColumn`, so the
+ // collapse-blank-line shortcut should not engage.
+ const { element } = setup('hello\n world', { minColumn: 4, indentation: 2 });
+ placeSelection(element, 'hello\n '.length);
+
+ const keyDown = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ code: 'Backspace',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(keyDown);
+
+ expect(keyDown.defaultPrevented).toBe(true);
+ // The fall-through path deletes a full `indentation` unit (2 chars)
+ // when the pre-caret content is purely indent.
+ expect(element.textContent).toBe('hello\n world');
+ });
+
+ it('Backspace at minColumn on a blank first line falls through (no previous line to land on)', () => {
+ // No `position.line > 0` to use, so we keep the default behavior.
+ const { element } = setup(' \nworld', { minColumn: 4, indentation: 2 });
+ placeSelection(element, ' '.length);
+
+ const keyDown = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ code: 'Backspace',
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(keyDown);
+
+ expect(keyDown.defaultPrevented).toBe(true);
+ expect(element.textContent).toBe(' \nworld');
+ });
});
// ---------------------------------------------------------------------------
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 8bd16275c..f70569f29 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -1086,8 +1086,33 @@ export const useEditable = (
edit.insert('', 0);
} else {
const position = getPosition(element);
- const match = blanklineRe.exec(position.content);
- edit.insert('', match ? -match[1].length : -1);
+ const { minColumn } = boundsRef.current;
+ // When the caret sits at `minColumn` on a blank (whitespace-only)
+ // line inside a clipped indent gutter, a normal Backspace would
+ // step into `[0, minColumn)` — visually invisible to the user
+ // since that range is hidden by the host. The user has nothing
+ // useful to delete on this line, so collapse the entire blank
+ // line and land the caret at the end of the previous line. This
+ // matches the mental model: "Backspace from an empty indented
+ // line removes the line."
+ const fullLine =
+ minColumn !== undefined && minColumn > 0
+ ? (toString(element).split('\n')[position.line] ?? '')
+ : '';
+ const collapseBlankIndent =
+ minColumn !== undefined &&
+ minColumn > 0 &&
+ position.line > 0 &&
+ position.content.length === minColumn &&
+ /^\s*$/.test(position.content) &&
+ fullLine.length === minColumn &&
+ /^\s*$/.test(fullLine);
+ if (collapseBlankIndent) {
+ edit.insert('', -(minColumn! + 1));
+ } else {
+ const match = blanklineRe.exec(position.content);
+ edit.insert('', match ? -match[1].length : -1);
+ }
}
} else if (opts!.indentation && event.key === 'Tab') {
event.preventDefault();
From 9c7289be449ff774fb59f8fc881c489abb410fa5 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 09:39:22 -0400
Subject: [PATCH 42/45] Performance improvements
Co-authored-by: Copilot
---
.../src/useCode/useEditable.test.ts | 70 ++++++
.../docs-infra/src/useCode/useEditable.ts | 203 ++++++++++++++----
2 files changed, 230 insertions(+), 43 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.test.ts b/packages/docs-infra/src/useCode/useEditable.test.ts
index ebbea985e..b4c213ad3 100644
--- a/packages/docs-infra/src/useCode/useEditable.test.ts
+++ b/packages/docs-infra/src/useCode/useEditable.test.ts
@@ -1484,6 +1484,39 @@ describe('useEditable', () => {
expect(event.defaultPrevented).toBe(false);
});
+ it('treats a blank intermediate line as a real next line for ArrowRight at end of line', () => {
+ // Regression: the chunked text-node walker used to short-circuit
+ // before recording that the next row exists when that row was
+ // empty, causing ArrowRight at the end of `text` to no-op instead
+ // of jumping into the spacer line. Documents like
+ // `text` / `` / `text` are extremely common in code samples.
+ const { element, placeInLine } = setupLined(['hello', '', 'world'], {
+ caretSelector: '.line',
+ });
+ placeInLine(0, 'hello'.length);
+
+ const event = dispatchArrow(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('hello\n'.length);
+ });
+
+ it('treats a blank intermediate line as a real next line for ArrowLeft at column 0', () => {
+ // Mirror of the above for the ArrowLeft gap-jump path: the caret
+ // is on the line *after* a blank one, and pressing ArrowLeft at
+ // column 0 should land at the end of the (zero-length) blank
+ // line rather than no-op.
+ const { element, placeInLine } = setupLined(['hello', '', 'world'], {
+ caretSelector: '.line',
+ });
+ placeInLine(2, 0);
+
+ const event = dispatchArrow(element, 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('hello\n'.length);
+ });
+
it('does not intercept vertical arrows so wrapped visual lines stay native', () => {
// ArrowUp/ArrowDown must remain unhijacked so browsers can navigate
// wrapped visual lines in `pre-wrap` layouts. Gap nodes styled with
@@ -1555,6 +1588,43 @@ describe('useEditable', () => {
expect(onBoundary).toHaveBeenCalledTimes(1);
});
+ it('treats a blank next line as a real line for ArrowDown at maxRow with caretSelector', () => {
+ // Boundary-path coverage for the chunked-walker bug: when the row
+ // immediately after `maxRow` is empty, ArrowDown must still cross
+ // into it (preserving column, then invoking onBoundary) instead of
+ // treating "blank line" as "no line" and no-op'ing.
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['hello', 'world', '', 'tail'], {
+ caretSelector: '.line',
+ maxRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 2);
+
+ const event = dispatchArrow(element, 'ArrowDown');
+
+ expect(event.defaultPrevented).toBe(true);
+ // Column 2 clamps to end of the blank line.
+ expect(caretOffset(element)).toBe('hello\nworld\n'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats a blank next line as a real line for ArrowRight at end of maxRow with caretSelector', () => {
+ const onBoundary = vi.fn();
+ const { element, placeInLine } = setupLined(['hello', 'world', '', 'tail'], {
+ caretSelector: '.line',
+ maxRow: 1,
+ onBoundary,
+ });
+ placeInLine(1, 'world'.length);
+
+ const event = dispatchArrow(element, 'ArrowRight');
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(caretOffset(element)).toBe('hello\nworld\n'.length);
+ expect(onBoundary).toHaveBeenCalledTimes(1);
+ });
+
it('synchronously moves caret to previous line on ArrowUp at minRow before invoking onBoundary', () => {
const onBoundary = vi.fn();
const { element, placeInLine } = setupLined(['head', 'hello', 'world'], {
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index f70569f29..1f34ed793 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -209,6 +209,133 @@ const toString = (element: HTMLElement): string => {
return content;
};
+interface LineInfo {
+ /** Full text of the requested line. */
+ currentLine: string;
+ /** Full text of `lineIndex - 1`. Empty when `lineIndex <= 0`. */
+ prevLine: string;
+ /** Full text of `lineIndex + 1`. Empty when there is no next line. */
+ nextLine: string;
+ /**
+ * True when a real line follows `currentLine` — including a blank
+ * line. False when the document ends at `currentLine` (matching the
+ * old `toString(element).split('\n').slice(0, -1)` semantics where
+ * the phantom empty entry after the trailing `\n` does not count as
+ * a next line).
+ */
+ hasNextLine: boolean;
+}
+
+/**
+ * Walk text nodes to extract the requested line plus its immediate
+ * neighbors without materializing the full document text or splitting
+ * it into a per-line array. Used by per-keystroke handlers (arrow keys,
+ * Backspace, gutter snapping) so they stay O(chars-on-touched-lines)
+ * instead of O(document-length) on every event.
+ *
+ * Walks each text node in document order and slices contiguous segments
+ * directly into the relevant accumulator (`prevLine` / `currentLine` /
+ * `nextLine`). Skips chunks belonging to lines we don't care about and
+ * exits as soon as the trailing `\n` of `lineIndex + 1` is consumed.
+ *
+ * Mirrors `toString(element).split('\n').slice(0, -1)` semantics:
+ *
+ * - `hasNextLine` is `true` whenever a real line follows `currentLine`,
+ * even if that line is blank — `"a\n\nb\n"` reports a next line for
+ * row 0. The phantom empty entry that `split` produces after the
+ * document's trailing `\n` is intentionally ignored.
+ * - The implicit trailing newline that `toString` appends when the DOM
+ * doesn't end with one has no effect: we walk raw text content.
+ */
+const getLineInfo = (element: HTMLElement, lineIndex: number): LineInfo => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let currentLine = '';
+ let prevLine = '';
+ let nextLine = '';
+ let hasNextLine = false;
+ let line = 0;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent!;
+ let segStart = 0;
+ for (let i = 0; i < text.length; i += 1) {
+ if (text[i] !== '\n') {
+ continue;
+ }
+ // Flush the segment that lives on `line` before crossing the newline.
+ if (segStart < i) {
+ const segment = text.slice(segStart, i);
+ if (line === lineIndex - 1) {
+ prevLine += segment;
+ } else if (line === lineIndex) {
+ currentLine += segment;
+ } else if (line === lineIndex + 1) {
+ nextLine += segment;
+ }
+ }
+ // We're about to cross the `\n` that terminates `line`. If `line`
+ // is the next line, we've now fully read it and confirmed it
+ // exists (a terminator means there is at least one more position
+ // in the document past `currentLine`'s end).
+ if (line === lineIndex + 1) {
+ hasNextLine = true;
+ return { currentLine, prevLine, nextLine, hasNextLine };
+ }
+ line += 1;
+ segStart = i + 1;
+ }
+ // Tail segment of this text node belongs to `line` (no newline yet).
+ if (segStart < text.length) {
+ const segment = text.slice(segStart);
+ if (line === lineIndex - 1) {
+ prevLine += segment;
+ } else if (line === lineIndex) {
+ currentLine += segment;
+ } else if (line === lineIndex + 1) {
+ // An unterminated tail on `lineIndex + 1` is the document's
+ // last (real) line — it counts as a next line. The phantom
+ // empty entry produced by `toString`'s trailing `\n` has no
+ // tail, so it correctly leaves `hasNextLine` false.
+ nextLine += segment;
+ hasNextLine = true;
+ }
+ }
+ }
+ return { currentLine, prevLine, nextLine, hasNextLine };
+};
+
+/**
+ * Convert a `(row, column)` coordinate into an absolute character offset
+ * by counting newlines through the editable's text nodes, exiting the
+ * moment we land on the requested row. Avoids the
+ * `toString(element).split('\n').slice(0, row).join('\n').length`
+ * round-trip — that pattern allocates the full document string and a
+ * full per-line array on every `edit.move({row, column})` call.
+ *
+ * If the row is past the end of the document, returns the document
+ * length plus `column` so the eventual `makeRange` clamps gracefully.
+ */
+const getOffsetAtLineColumn = (element: HTMLElement, row: number, column: number): number => {
+ if (row <= 0) {
+ return Math.max(0, column);
+ }
+ let offset = 0;
+ let line = 0;
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent!;
+ for (let i = 0; i < text.length; i += 1) {
+ offset += 1;
+ if (text[i] === '\n') {
+ line += 1;
+ if (line === row) {
+ return offset + column;
+ }
+ }
+ }
+ }
+ return offset + column;
+};
+
const repairUnexpectedLineMerge = (
newContent: string,
previousContent: string | null,
@@ -627,16 +754,8 @@ export const useEditable = (
const { current: element } = elementRef;
if (element) {
element.focus();
- let position = 0;
- if (typeof pos === 'number') {
- position = pos;
- } else {
- const lines = toString(element).split('\n').slice(0, pos.row);
- if (pos.row) {
- position += lines.join('\n').length + 1;
- }
- position += pos.column;
- }
+ const position =
+ typeof pos === 'number' ? pos : getOffsetAtLineColumn(element, pos.row, pos.column);
const cursorRange = makeRange(element, position);
adjustCursorAtNewlineBoundary(cursorRange);
setCurrentRange(cursorRange);
@@ -955,7 +1074,9 @@ export const useEditable = (
}
// Only snap when the gutter is actually whitespace — otherwise the
// line is shorter than `minColumn` and there's nowhere to snap to.
- const lineText = toString(element).split('\n')[position.line] ?? '';
+ // `getLineInfo` walks just enough text nodes to read the current
+ // line; avoids materializing the full document text on every click.
+ const lineText = getLineInfo(element, position.line).currentLine;
if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
return;
}
@@ -1095,18 +1216,18 @@ export const useEditable = (
// line and land the caret at the end of the previous line. This
// matches the mental model: "Backspace from an empty indented
// line removes the line."
- const fullLine =
- minColumn !== undefined && minColumn > 0
- ? (toString(element).split('\n')[position.line] ?? '')
- : '';
- const collapseBlankIndent =
+ //
+ // Walk only enough text nodes to read the current line — we
+ // don't need the rest of the document on every Backspace.
+ const couldCollapse =
minColumn !== undefined &&
minColumn > 0 &&
position.line > 0 &&
position.content.length === minColumn &&
- /^\s*$/.test(position.content) &&
- fullLine.length === minColumn &&
- /^\s*$/.test(fullLine);
+ /^\s*$/.test(position.content);
+ const fullLine = couldCollapse ? getLineInfo(element, position.line).currentLine : '';
+ const collapseBlankIndent =
+ couldCollapse && fullLine.length === minColumn! && /^\s*$/.test(fullLine);
if (collapseBlankIndent) {
edit.insert('', -(minColumn! + 1));
} else {
@@ -1157,12 +1278,15 @@ export const useEditable = (
const { minColumn, minRow, maxRow, onBoundary, caretSelector } = boundsRef.current;
const position = getPosition(element);
const column = position.content.length;
- const allLines = toString(element).split('\n');
- // `toString` guarantees a trailing `\n`, so the split produces a
- // phantom empty entry at the end. Drop it so `lines.length - 1` is
- // the index of the real last line.
- const lines = allLines.length > 1 ? allLines.slice(0, -1) : allLines;
- const lineText = lines[position.line] ?? '';
+ // Walk just enough of the document to gather the current line
+ // and its immediate neighbors instead of allocating the entire
+ // document string and a full per-line array on every keypress.
+ const {
+ currentLine: lineText,
+ prevLine,
+ nextLine,
+ hasNextLine,
+ } = getLineInfo(element, position.line);
const lineIsIndented =
minColumn !== undefined &&
lineText.length >= minColumn &&
@@ -1194,9 +1318,10 @@ export const useEditable = (
// when we need to move synchronously across the inter-line gap
// text nodes that `caretSelector`-rendered content places between
// `.line` spans (a native arrow press would otherwise drop the
- // caret *in* the gap).
- const moveToLine = (targetRow: number, desiredColumn: number) => {
- const targetLine = lines[targetRow] ?? '';
+ // caret *in* the gap). The caller passes the target line's text
+ // (already in hand from `getLineInfo`) so we don't re-walk the
+ // document.
+ const moveToLine = (targetRow: number, targetLine: string, desiredColumn: number) => {
let targetColumn = Math.min(desiredColumn, targetLine.length);
if (
minColumn !== undefined &&
@@ -1218,7 +1343,7 @@ export const useEditable = (
// (e.g. the literal `\n` between `.line` spans), trapping
// it in the "between lines" area after the host expands.
event.preventDefault();
- moveToLine(position.line - 1, column);
+ moveToLine(position.line - 1, prevLine, column);
if (onBoundary) {
state.skipNextRestore = true;
onBoundary();
@@ -1234,9 +1359,9 @@ export const useEditable = (
}
} else if (event.key === 'ArrowDown') {
if (atVisibleEnd) {
- if (caretInLine && position.line < lines.length - 1) {
+ if (caretInLine && hasNextLine) {
event.preventDefault();
- moveToLine(position.line + 1, column);
+ moveToLine(position.line + 1, nextLine, column);
if (onBoundary) {
state.skipNextRestore = true;
onBoundary();
@@ -1252,7 +1377,6 @@ export const useEditable = (
if (atVisibleStart && atLineStart) {
if (caretInLine && position.line > 0) {
event.preventDefault();
- const prevLine = lines[position.line - 1] ?? '';
edit.move({ row: position.line - 1, column: prevLine.length });
if (onBoundary) {
state.skipNextRestore = true;
@@ -1271,7 +1395,6 @@ export const useEditable = (
position.line > 0
) {
event.preventDefault();
- const prevLine = lines[position.line - 1] ?? '';
edit.move({ row: position.line - 1, column: prevLine.length });
} else if (caretInLine && column === 0 && position.line > 0) {
// With non-selectable gaps between lines the browser would
@@ -1279,14 +1402,13 @@ export const useEditable = (
// a no-op. Jump synchronously to the end of the previous
// line instead.
event.preventDefault();
- const prevLine = lines[position.line - 1] ?? '';
edit.move({ row: position.line - 1, column: prevLine.length });
}
} else if (event.key === 'ArrowRight') {
if (atVisibleEnd && atLineEnd) {
- if (caretInLine && position.line < lines.length - 1) {
+ if (caretInLine && hasNextLine) {
event.preventDefault();
- moveToLine(position.line + 1, 0);
+ moveToLine(position.line + 1, nextLine, 0);
if (onBoundary) {
state.skipNextRestore = true;
onBoundary();
@@ -1297,12 +1419,7 @@ export const useEditable = (
} else {
event.preventDefault();
}
- } else if (
- minColumn !== undefined &&
- column === lineText.length &&
- position.line < lines.length - 1
- ) {
- const nextLine = lines[position.line + 1] ?? '';
+ } else if (minColumn !== undefined && column === lineText.length && hasNextLine) {
const nextIsIndented =
nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
if (nextIsIndented) {
@@ -1314,7 +1431,7 @@ export const useEditable = (
event.preventDefault();
edit.move({ row: position.line + 1, column: 0 });
}
- } else if (caretInLine && atLineEnd && position.line < lines.length - 1) {
+ } else if (caretInLine && atLineEnd && hasNextLine) {
event.preventDefault();
edit.move({ row: position.line + 1, column: 0 });
}
From 1446d2161e69c2111228232acddda90e224e243f Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 10:52:22 -0400
Subject: [PATCH 43/45] Improve TS support
Co-authored-by: Copilot
---
.../docs-infra/src/useCode/useEditable.ts | 185 +++++++++++-------
1 file changed, 119 insertions(+), 66 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 1f34ed793..5ffc9932e 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -63,14 +63,42 @@ const observerSettings = {
subtree: true,
};
-const getCurrentRange = () => window.getSelection()!.getRangeAt(0)!;
+const getCurrentRange = (): Range => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ // Internal helper — only called from event handlers and edit methods
+ // that have already verified there is an active selection. Throwing
+ // here surfaces contract violations early instead of letting them
+ // explode further down the call stack.
+ throw new Error('useEditable: expected an active selection');
+ }
+ return selection.getRangeAt(0);
+};
const setCurrentRange = (range: Range) => {
- const selection = window.getSelection()!;
+ 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.
+ */
+const asElement = (node: Node | null | undefined): Element | null =>
+ node instanceof Element ? node : null;
+
+/**
+ * Pull the next element out of a `SHOW_ELEMENT` `TreeWalker` with a
+ * runtime check rather than a type cast. Tree walkers configured for
+ * `SHOW_ELEMENT` only emit elements in practice, but the DOM type
+ * exposes `Node | null`.
+ */
+const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode());
+
const isUndoRedoKey = (event: KeyboardEvent): boolean =>
(event.metaKey || event.ctrlKey) && !event.altKey && event.code === 'KeyZ';
@@ -174,7 +202,8 @@ const extractLeadingPerLine = (text: string, firstLineCount: number, restCount:
// and is consumed across consecutive text nodes so that indent nested
// inside multiple wrapper spans is still removed correctly.
const stripLeadingPerLineDom = (root: Node, firstLineCount: number, restCount: number): void => {
- const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const ownerDoc = root.ownerDocument ?? document;
+ const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let atLineStart = firstLineCount > 0;
let remaining = firstLineCount;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
@@ -255,7 +284,7 @@ const getLineInfo = (element: HTMLElement, lineIndex: number): LineInfo => {
let hasNextLine = false;
let line = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent!;
+ const text = node.textContent ?? '';
let segStart = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] !== '\n') {
@@ -322,7 +351,7 @@ const getOffsetAtLineColumn = (element: HTMLElement, row: number, column: number
let line = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent!;
+ const text = node.textContent ?? '';
for (let i = 0; i < text.length; i += 1) {
offset += 1;
if (text[i] === '\n') {
@@ -387,7 +416,8 @@ const repairUnexpectedLineMerge = (
};
const setStart = (range: Range, node: Node, offset: number) => {
- if (offset < node.textContent!.length) {
+ const length = (node.textContent ?? '').length;
+ if (offset < length) {
range.setStart(node, offset);
} else {
range.setStartAfter(node);
@@ -395,7 +425,8 @@ const setStart = (range: Range, node: Node, offset: number) => {
};
const setEnd = (range: Range, node: Node, offset: number) => {
- if (offset < node.textContent!.length) {
+ const length = (node.textContent ?? '').length;
+ if (offset < length) {
range.setEnd(node, offset);
} else {
range.setEndAfter(node);
@@ -416,7 +447,7 @@ const getPosition = (element: HTMLElement): Position => {
let lineContent = '';
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent!;
+ const text = node.textContent ?? '';
const isTarget = node === range.startContainer;
const upTo = isTarget ? range.startOffset : text.length;
@@ -468,7 +499,7 @@ const makeRange = (element: HTMLElement, start: number, end?: number): Range =>
let position = start;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const length = node.textContent!.length;
+ const length = (node.textContent ?? '').length;
if (current + length >= position) {
const offset = position - current;
if (position === start) {
@@ -527,12 +558,13 @@ const adjustCursorAtNewlineBoundary = (range: Range): void => {
}
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 === startContainer.textContent!.length &&
- startContainer.textContent!.endsWith('\n')
+ startOffset === startText.length &&
+ startText.endsWith('\n')
) {
const next = nextTextNode(startContainer);
if (next) {
@@ -546,7 +578,8 @@ const adjustCursorAtNewlineBoundary = (range: Range): void => {
// 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];
- if (prevChild?.nodeType === Node.TEXT_NODE && prevChild.textContent!.endsWith('\n')) {
+ const prevText = prevChild?.textContent ?? '';
+ if (prevChild?.nodeType === Node.TEXT_NODE && prevText.endsWith('\n')) {
const next = nextTextNode(prevChild);
if (next) {
range.setStart(next, 0);
@@ -668,9 +701,10 @@ export const useEditable = (
onChange: (text: string, position: Position) => void,
opts?: Options,
): Edit => {
- if (!opts) {
- opts = {};
- }
+ // Normalize once into a non-optional local so closures (effects, the
+ // edit object, event handlers) can read `config.X` directly without
+ // any non-null assertions on `opts`.
+ const config: Options = opts ?? {};
const unblock = React.useState([])[1];
const state = React.useState(() => ({
@@ -703,17 +737,17 @@ export const useEditable = (
// time they change (e.g. when a host expands a collapsed code block),
// which causes the browser to drop focus mid-animation.
const boundsRef = React.useRef({
- minColumn: opts.minColumn,
- minRow: opts.minRow,
- maxRow: opts.maxRow,
- onBoundary: opts.onBoundary,
- caretSelector: opts.caretSelector,
+ minColumn: config.minColumn,
+ minRow: config.minRow,
+ maxRow: config.maxRow,
+ onBoundary: config.onBoundary,
+ caretSelector: config.caretSelector,
});
- boundsRef.current.minColumn = opts.minColumn;
- boundsRef.current.minRow = opts.minRow;
- boundsRef.current.maxRow = opts.maxRow;
- boundsRef.current.onBoundary = opts.onBoundary;
- boundsRef.current.caretSelector = opts.caretSelector;
+ boundsRef.current.minColumn = config.minColumn;
+ boundsRef.current.minRow = config.minRow;
+ boundsRef.current.maxRow = config.maxRow;
+ boundsRef.current.onBoundary = config.onBoundary;
+ boundsRef.current.caretSelector = config.caretSelector;
// useMemo with [] is a performance hint, not a semantic guarantee — React 19
// may discard the cache and recreate the object. useState with a lazy
@@ -762,10 +796,16 @@ export const useEditable = (
}
},
getState() {
- const { current: element } = elementRef;
- const text = toString(element!);
- const position = getPosition(element!);
- return { text, position };
+ const element = elementRef.current;
+ if (!element) {
+ // Pre-mount / unmounted: return an empty snapshot so callers
+ // that subscribe before the ref is attached get a stable shape.
+ return {
+ text: '',
+ position: { position: 0, extent: 0, content: '', line: 0 },
+ };
+ }
+ return { text: toString(element), position: getPosition(element) };
},
}));
@@ -779,7 +819,7 @@ export const useEditable = (
state.onChange = onChange;
- if (!elementRef.current || opts!.disabled) {
+ if (!elementRef.current || config.disabled) {
return undefined;
}
@@ -814,13 +854,16 @@ export const useEditable = (
return undefined;
}
- if (!elementRef.current || opts!.disabled) {
+ if (!elementRef.current || config.disabled) {
state.history.length = 0;
state.historyAt = -1;
return undefined;
}
- const element = elementRef.current!;
+ const element = elementRef.current;
+ if (!element) {
+ return undefined;
+ }
if (state.position) {
element.focus();
const { position, extent } = state.position;
@@ -844,13 +887,13 @@ export const useEditable = (
element.style.whiteSpace = 'pre-wrap';
}
- if (opts!.indentation) {
- const tabSizeValue = `${opts!.indentation}`;
+ if (config.indentation) {
+ const tabSizeValue = `${config.indentation}`;
element.style.setProperty('-moz-tab-size', tabSizeValue);
element.style.tabSize = tabSizeValue;
}
- const indentPattern = `${' '.repeat(opts!.indentation || 0)}`;
+ const indentPattern = `${' '.repeat(config.indentation || 0)}`;
const indentRe = new RegExp(`^(?:${indentPattern})`);
const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`);
@@ -864,7 +907,7 @@ export const useEditable = (
// Using !state.position would block recording the initial state: state.position is
// only set by flushChanges() which runs on keyup — after the first edit. Switching
// to rangeCount === 0 lets the very first keydown snapshot the pre-edit content.
- if (!elementRef.current || window.getSelection()!.rangeCount === 0) {
+ if (!elementRef.current || (window.getSelection()?.rangeCount ?? 0) === 0) {
return null;
}
@@ -915,7 +958,10 @@ export const useEditable = (
);
state.position = position;
while (state.queue.length > 0) {
- const mutation = state.queue.pop()!;
+ const mutation = state.queue.pop();
+ if (!mutation) {
+ break;
+ }
if (mutation.oldValue !== null) {
mutation.target.textContent = mutation.oldValue;
}
@@ -966,10 +1012,7 @@ export const useEditable = (
return false;
}
const startContainer = snapRange.startContainer;
- const startElement =
- startContainer.nodeType === Node.ELEMENT_NODE
- ? (startContainer as Element)
- : startContainer.parentElement;
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
// Caret is already inside a `.line` (or equivalent) — no snap needed.
if (startElement?.closest(caretSelector)) {
return false;
@@ -1198,7 +1241,7 @@ export const useEditable = (
// Route plain text input through the controlled insert path instead.
event.preventDefault();
edit.insert(event.key);
- } else if ((!hasPlaintextSupport || opts!.indentation) && event.key === 'Backspace') {
+ } else if ((!hasPlaintextSupport || config.indentation) && event.key === 'Backspace') {
// Firefox Quirk: Since plaintext-only is unsupported we must
// ensure that only a single character is deleted
event.preventDefault();
@@ -1225,17 +1268,20 @@ export const useEditable = (
position.line > 0 &&
position.content.length === minColumn &&
/^\s*$/.test(position.content);
- const fullLine = couldCollapse ? getLineInfo(element, position.line).currentLine : '';
- const collapseBlankIndent =
- couldCollapse && fullLine.length === minColumn! && /^\s*$/.test(fullLine);
- if (collapseBlankIndent) {
- edit.insert('', -(minColumn! + 1));
- } else {
- const match = blanklineRe.exec(position.content);
- edit.insert('', match ? -match[1].length : -1);
+ if (couldCollapse && minColumn !== undefined) {
+ // The redundant `minColumn !== undefined` check pins TS's
+ // narrowing across the boundary so we can use `minColumn`
+ // as a number directly without an assertion.
+ const fullLine = getLineInfo(element, position.line).currentLine;
+ if (fullLine.length === minColumn && /^\s*$/.test(fullLine)) {
+ edit.insert('', -(minColumn + 1));
+ return;
+ }
}
+ const match = blanklineRe.exec(position.content);
+ edit.insert('', match ? -match[1].length : -1);
}
- } else if (opts!.indentation && event.key === 'Tab') {
+ } else if (config.indentation && event.key === 'Tab') {
event.preventDefault();
const position = getPosition(element);
const start = position.position - position.content.length;
@@ -1245,7 +1291,7 @@ export const useEditable = (
position.content.replace(indentRe, '') +
content.slice(start + position.content.length)
: content.slice(0, start) +
- (opts!.indentation ? ' '.repeat(opts!.indentation) : '\t') +
+ (config.indentation ? ' '.repeat(config.indentation) : '\t') +
content.slice(start);
edit.update(newContent);
} else if (
@@ -1306,10 +1352,7 @@ export const useEditable = (
caretSelector !== undefined &&
(() => {
const startContainer = range.startContainer;
- const startElement =
- startContainer.nodeType === Node.ELEMENT_NODE
- ? (startContainer as Element)
- : startContainer.parentElement;
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
return !!startElement?.closest(caretSelector);
})();
@@ -1532,14 +1575,18 @@ export const useEditable = (
const onSelect = (event: Event) => {
// Chrome Quirk: The contenteditable may lose its selection immediately on first focus
- state.position =
- window.getSelection()!.rangeCount && event.target === element ? getPosition(element) : null;
+ const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
+ state.position = hasRange && event.target === element ? getPosition(element) : null;
};
const onPaste = (event: HTMLElementEventMap['paste']) => {
event.preventDefault();
+ const clipboard = event.clipboardData;
+ if (!clipboard) {
+ return;
+ }
state.pendingContent = trackState(true) ?? toString(element);
- edit.insert(event.clipboardData!.getData('text/plain'));
+ edit.insert(clipboard.getData('text/plain'));
flushChanges(true);
};
@@ -1615,8 +1662,7 @@ export const useEditable = (
// editable root and inline styles onto each rebuilt wrapper so
// rich-text paste targets keep the original highlighting.
const cac = range.commonAncestorContainer;
- const anchor: Element | null =
- cac.nodeType === Node.ELEMENT_NODE ? (cac as Element) : cac.parentElement;
+ const anchor: Element | null = asElement(cac) ?? cac.parentElement;
let rootContent: Node = fragment;
// The innermost reconstructed wrapper, if any. The style-inlining
// pass below walks from here so the clone walker stays aligned
@@ -1626,7 +1672,14 @@ export const useEditable = (
let current: Element | null = anchor;
let innermost: Element | null = null;
while (current && current !== element) {
- const ancestorClone = current.cloneNode(false) as Element;
+ const cloned = current.cloneNode(false);
+ // `Element.cloneNode` returns an Element; the runtime check
+ // exists purely to satisfy the DOM lib's `Node` return type.
+ if (!(cloned instanceof Element)) {
+ current = current.parentElement;
+ continue;
+ }
+ const ancestorClone = cloned;
if (view) {
const computed = view.getComputedStyle(current);
let inline = ancestorClone.getAttribute('style') ?? '';
@@ -1667,8 +1720,8 @@ export const useEditable = (
},
);
const cloneWalker = doc.createTreeWalker(cloneStylingRoot, NodeFilter.SHOW_ELEMENT);
- let source = sourceWalker.nextNode() as Element | null;
- let clone = cloneWalker.nextNode() as Element | null;
+ let source = nextElement(sourceWalker);
+ let clone = nextElement(cloneWalker);
while (source && clone) {
if (source.tagName === clone.tagName) {
const computed = view.getComputedStyle(source);
@@ -1683,8 +1736,8 @@ export const useEditable = (
clone.setAttribute('style', inline);
}
}
- source = sourceWalker.nextNode() as Element | null;
- clone = cloneWalker.nextNode() as Element | null;
+ source = nextElement(sourceWalker);
+ clone = nextElement(cloneWalker);
}
// Apply the editable's own typography to the wrapper so the
// pasted block matches the source font/size even when only a
From d08a735832b4baf02f131a78a339d09d02539868 Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 11:24:15 -0400
Subject: [PATCH 44/45] Refactor
Co-authored-by: Copilot
---
.../src/useCode/cloneRangeWithInlineStyles.ts | 177 +++++
.../src/useCode/stripLeadingPerLine.ts | 116 +++
.../docs-infra/src/useCode/useEditable.ts | 684 ++----------------
.../src/useCode/useEditableUtils.ts | 430 +++++++++++
4 files changed, 769 insertions(+), 638 deletions(-)
create mode 100644 packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts
create mode 100644 packages/docs-infra/src/useCode/stripLeadingPerLine.ts
create mode 100644 packages/docs-infra/src/useCode/useEditableUtils.ts
diff --git a/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts b/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts
new file mode 100644
index 000000000..8977e8b04
--- /dev/null
+++ b/packages/docs-infra/src/useCode/cloneRangeWithInlineStyles.ts
@@ -0,0 +1,177 @@
+/**
+ * Clone a `Range` into a self-contained wrapper element with computed
+ * styles inlined onto every cloned descendant, so that pasting into
+ * rich-text targets (email, Word, Notion, etc.) preserves the source's
+ * visual styling without depending on the source page's stylesheet.
+ *
+ * The wrapper defaults to `
` so monospace + whitespace context
+ * survives a copy/paste round-trip. The original ancestor chain
+ * between `range.commonAncestorContainer` and `root` is reconstructed
+ * inside the wrapper so a selection living entirely inside a styled
+ * descendant (e.g. one token of a syntax-highlighted line) keeps that
+ * wrapper in the clipboard payload.
+ */
+
+interface InlineStyleOptions {
+ /**
+ * Tag name for the wrapper element. Defaults to `'pre'` so monospace
+ * + whitespace context survives a copy/paste round-trip.
+ */
+ wrapperTag?: string;
+ /**
+ * Class name applied to the wrapper. Defaults to `root.className` so
+ * consumers that scope styles by class keep matching when the snippet
+ * is pasted into a richer environment that loads the same stylesheet.
+ */
+ className?: string;
+ /**
+ * Computed-style properties inlined onto every cloned descendant.
+ * Keep this list short — each property is read via
+ * `getComputedStyle` per node.
+ */
+ elementStyleProps?: readonly string[];
+ /**
+ * Computed-style properties read from `root` and inlined onto the
+ * wrapper. Use to carry typography (font-family, font-size,
+ * line-height, …) onto the wrapper so the pasted block matches the
+ * source even when only a descendant was selected.
+ */
+ rootStyleProps?: readonly string[];
+ /**
+ * Static CSS prepended to the wrapper's `style` attribute, before any
+ * computed properties. Useful for visual chrome (padding, rounded
+ * corners) that does not depend on the source.
+ */
+ rootStaticStyles?: string;
+}
+
+const asElement = (node: Node | null | undefined): Element | null =>
+ node instanceof Element ? node : null;
+
+const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode());
+
+const inlineComputedStyles = (
+ target: Element,
+ computed: CSSStyleDeclaration,
+ props: readonly string[],
+): void => {
+ let inline = target.getAttribute('style') ?? '';
+ for (const prop of props) {
+ const value = computed.getPropertyValue(prop);
+ if (value && value !== 'normal' && value !== 'none' && value !== 'auto') {
+ inline += `${prop}:${value};`;
+ }
+ }
+ if (inline) {
+ target.setAttribute('style', inline);
+ }
+};
+
+export const cloneRangeWithInlineStyles = (
+ root: HTMLElement,
+ range: Range,
+ options: InlineStyleOptions = {},
+): HTMLElement => {
+ const {
+ wrapperTag = 'pre',
+ className = root.className,
+ elementStyleProps = [],
+ rootStyleProps = [],
+ rootStaticStyles = '',
+ } = options;
+
+ const doc = root.ownerDocument;
+ const view = doc.defaultView;
+ const fragment = range.cloneContents();
+ const container = doc.createElement(wrapperTag);
+ if (className) {
+ container.className = className;
+ }
+
+ // `Range.cloneContents` returns the descendants of the
+ // `commonAncestorContainer` but never the ancestor itself, so any
+ // selection that lives entirely inside a styled wrapper (a single
+ // text node inside a token, or multiple children of the same token)
+ // loses that wrapper in the clipboard payload. The computed-style
+ // inlining pass below has nothing to inline onto in that case.
+ // Reconstruct the ancestor chain up to (but not including) `root`
+ // and inline styles onto each rebuilt wrapper so rich-text paste
+ // targets keep the original highlighting.
+ const cac = range.commonAncestorContainer;
+ const anchor: Element | null = asElement(cac) ?? cac.parentElement;
+ let rootContent: Node = fragment;
+ // The innermost reconstructed wrapper, if any. The style-inlining
+ // pass below walks from here so the clone walker stays aligned with
+ // the source walker (which starts from the CAC's descendants).
+ let cloneStylingRoot: Node = container;
+ if (anchor && anchor !== root && root.contains(anchor)) {
+ let current: Element | null = anchor;
+ let innermost: Element | null = null;
+ while (current && current !== root) {
+ const cloned = current.cloneNode(false);
+ // `Element.cloneNode` returns an Element; the runtime check
+ // exists purely to satisfy the DOM lib's `Node` return type.
+ if (!(cloned instanceof Element)) {
+ current = current.parentElement;
+ continue;
+ }
+ if (view && elementStyleProps.length > 0) {
+ inlineComputedStyles(cloned, view.getComputedStyle(current), elementStyleProps);
+ }
+ cloned.appendChild(rootContent);
+ rootContent = cloned;
+ if (innermost === null) {
+ innermost = cloned;
+ }
+ current = current.parentElement;
+ }
+ if (innermost) {
+ cloneStylingRoot = innermost;
+ }
+ }
+ container.appendChild(rootContent);
+
+ if (view && elementStyleProps.length > 0) {
+ // Walk the CAC's descendants and mirror them onto the cloned
+ // descendants of the innermost reconstructed wrapper. Both
+ // walkers exclude their root, so as long as the roots correspond
+ // (CAC ↔ innermost reconstructed wrapper, or CAC ↔ wrapper when
+ // there is no reconstruction) the per-step pairing is correct.
+ const sourceWalker = doc.createTreeWalker(
+ range.commonAncestorContainer,
+ NodeFilter.SHOW_ELEMENT,
+ {
+ acceptNode: (node) =>
+ range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
+ },
+ );
+ const cloneWalker = doc.createTreeWalker(cloneStylingRoot, NodeFilter.SHOW_ELEMENT);
+ let source = nextElement(sourceWalker);
+ let clone = nextElement(cloneWalker);
+ while (source && clone) {
+ if (source.tagName === clone.tagName) {
+ inlineComputedStyles(clone, view.getComputedStyle(source), elementStyleProps);
+ }
+ source = nextElement(sourceWalker);
+ clone = nextElement(cloneWalker);
+ }
+ }
+
+ if (view && (rootStyleProps.length > 0 || rootStaticStyles)) {
+ let rootInline = rootStaticStyles;
+ if (rootStyleProps.length > 0) {
+ const rootComputed = view.getComputedStyle(root);
+ for (const prop of rootStyleProps) {
+ const value = rootComputed.getPropertyValue(prop);
+ if (value) {
+ rootInline += `${prop}:${value};`;
+ }
+ }
+ }
+ if (rootInline) {
+ container.setAttribute('style', rootInline);
+ }
+ }
+
+ return container;
+};
diff --git a/packages/docs-infra/src/useCode/stripLeadingPerLine.ts b/packages/docs-infra/src/useCode/stripLeadingPerLine.ts
new file mode 100644
index 000000000..57331f6c0
--- /dev/null
+++ b/packages/docs-infra/src/useCode/stripLeadingPerLine.ts
@@ -0,0 +1,116 @@
+/**
+ * Per-line leading-whitespace utilities. Useful when copying text out
+ * of a region whose first `N` columns are visually clipped (an indent
+ * gutter, a line-number column, etc.) so the clipboard payload matches
+ * what the user sees rather than including the hidden prefix.
+ *
+ * Each helper takes two budgets:
+ *
+ * - `firstLineCount` — the budget for the first line. Typically
+ * `max(0, gutterWidth - selectionStartColumn)` so a selection that
+ * begins mid-gutter only loses the gutter portion still inside the
+ * selection.
+ * - `restCount` — the budget for every line after a `\n`, normally the
+ * full gutter width.
+ */
+
+/**
+ * Strip leading whitespace per line from a plain-text string. Returns
+ * the trimmed text. Up to `firstLineCount` characters of leading space
+ * or tab are removed from the first line, and up to `restCount` from
+ * every line after a `\n`.
+ */
+export const stripLeadingPerLine = (
+ text: string,
+ firstLineCount: number,
+ restCount: number,
+): string => {
+ const lines = text.split('\n');
+ for (let i = 0; i < lines.length; i += 1) {
+ const budget = i === 0 ? firstLineCount : restCount;
+ if (budget <= 0) {
+ continue;
+ }
+ const line = lines[i];
+ let stripped = 0;
+ while (stripped < budget && stripped < line.length) {
+ const ch = line[stripped];
+ if (ch !== ' ' && ch !== '\t') {
+ break;
+ }
+ stripped += 1;
+ }
+ lines[i] = line.slice(stripped);
+ }
+ return lines.join('\n');
+};
+
+/**
+ * Mirror of `stripLeadingPerLine` that returns *what was stripped* per
+ * line, joined with `\n`. Useful for "lossless cut": the clipboard
+ * payload omits the clipped prefix, but the underlying document keeps
+ * it by re-inserting the extracted prefix string at the selection
+ * location.
+ */
+export const extractLeadingPerLine = (
+ text: string,
+ firstLineCount: number,
+ restCount: number,
+): string => {
+ const lines = text.split('\n');
+ const prefixes: string[] = [];
+ for (let i = 0; i < lines.length; i += 1) {
+ const budget = i === 0 ? firstLineCount : restCount;
+ if (budget <= 0) {
+ prefixes.push('');
+ continue;
+ }
+ const line = lines[i];
+ let stripped = 0;
+ while (stripped < budget && stripped < line.length) {
+ const ch = line[stripped];
+ if (ch !== ' ' && ch !== '\t') {
+ break;
+ }
+ stripped += 1;
+ }
+ prefixes.push(line.slice(0, stripped));
+ }
+ return prefixes.join('\n');
+};
+
+/**
+ * DOM-aware variant of `stripLeadingPerLine`: walks every text node
+ * under `root` in document order and removes leading whitespace at the
+ * start of each logical line. The budget refills to `restCount` after
+ * every `\n` and is consumed across consecutive text nodes, so indent
+ * nested inside multiple wrapper spans is still removed correctly.
+ */
+export const stripLeadingPerLineDom = (
+ root: Node,
+ firstLineCount: number,
+ restCount: number,
+): void => {
+ const ownerDoc = root.ownerDocument ?? document;
+ const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let atLineStart = firstLineCount > 0;
+ let remaining = firstLineCount;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? '';
+ let result = '';
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === '\n') {
+ atLineStart = restCount > 0;
+ remaining = restCount;
+ result += ch;
+ } else if (atLineStart && remaining > 0 && (ch === ' ' || ch === '\t')) {
+ remaining -= 1;
+ } else {
+ atLineStart = false;
+ result += ch;
+ }
+ }
+ node.textContent = result;
+ }
+};
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 5ffc9932e..3e514b69a 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -47,12 +47,29 @@ SOFTWARE.
import * as React from 'react';
-export interface Position {
- position: number;
- extent: number;
- content: string;
- line: number;
-}
+import {
+ type Position,
+ adjustCursorAtNewlineBoundary,
+ asElement,
+ getCurrentRange,
+ getLineInfo,
+ getOffsetAtLineColumn,
+ getPosition,
+ isPlaintextInputKey,
+ isUndoRedoKey,
+ makeRange,
+ repairUnexpectedLineMerge,
+ setCurrentRange,
+ toString,
+} from './useEditableUtils';
+import { cloneRangeWithInlineStyles } from './cloneRangeWithInlineStyles';
+import {
+ extractLeadingPerLine,
+ stripLeadingPerLine,
+ stripLeadingPerLineDom,
+} from './stripLeadingPerLine';
+
+export type { Position } from './useEditableUtils';
type History = [Position, string];
@@ -63,58 +80,10 @@ const observerSettings = {
subtree: true,
};
-const getCurrentRange = (): Range => {
- const selection = window.getSelection();
- if (!selection || selection.rangeCount === 0) {
- // Internal helper — only called from event handlers and edit methods
- // that have already verified there is an active selection. Throwing
- // here surfaces contract violations early instead of letting them
- // explode further down the call stack.
- throw new Error('useEditable: expected an active selection');
- }
- return selection.getRangeAt(0);
-};
-
-const setCurrentRange = (range: Range) => {
- const selection = window.getSelection();
- if (!selection) {
- return;
- }
- selection.empty();
- selection.addRange(range);
-};
-
-/**
- * Narrow a `Node | null` to `Element | null` using a runtime check so
- * downstream code can reason about element-only APIs without a cast.
- */
-const asElement = (node: Node | null | undefined): Element | null =>
- node instanceof Element ? node : null;
-
-/**
- * Pull the next element out of a `SHOW_ELEMENT` `TreeWalker` with a
- * runtime check rather than a type cast. Tree walkers configured for
- * `SHOW_ELEMENT` only emit elements in practice, but the DOM type
- * exposes `Node | null`.
- */
-const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode());
-
-const isUndoRedoKey = (event: KeyboardEvent): boolean =>
- (event.metaKey || event.ctrlKey) && !event.altKey && event.code === 'KeyZ';
-
-const isPlaintextInputKey = (event: KeyboardEvent): boolean => {
- const usesAltGraph =
- typeof event.getModifierState === 'function' && event.getModifierState('AltGraph');
-
- return (
- event.key.length === 1 && !event.metaKey && !event.ctrlKey && (!event.altKey || usesAltGraph)
- );
-};
-
-// Computed-style properties inlined onto each element in the copied HTML
-// fragment so external paste targets render with the same syntax
+// Computed-style properties inlined onto each element in the copied
+// HTML fragment so external paste targets render with the same syntax
// highlighting without needing our stylesheet.
-const CLIPBOARD_STYLE_PROPS = [
+const CLIPBOARD_ELEMENT_STYLE_PROPS = [
'color',
'background-color',
'font-weight',
@@ -132,463 +101,13 @@ const CLIPBOARD_ROOT_STYLE_PROPS = [
'background-color',
'color',
];
-// A small amount of padding + rounded corners gives the pasted snippet a
-// card-like appearance in rich-text targets without overriding the
+
+// A small amount of padding + rounded corners gives the pasted snippet
+// a card-like appearance in rich-text targets without overriding the
// background or font that consumers already control via the editable's
// own styles.
const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;';
-// Strip leading whitespace characters per line of a plain-text string,
-// used to drop the clipped indent gutter (`minColumn`) from clipboard
-// payloads so the pasted snippet matches what the user sees.
-//
-// `firstLineCount` is the budget for the first line — typically
-// `max(0, minColumn - startColumn)` so that a selection starting
-// mid-gutter only loses the gutter portion still inside the selection.
-// `restCount` is the budget for every line after a `\n`, normally the
-// full `minColumn`.
-const stripLeadingPerLine = (text: string, firstLineCount: number, restCount: number): string => {
- const lines = text.split('\n');
- for (let i = 0; i < lines.length; i += 1) {
- const budget = i === 0 ? firstLineCount : restCount;
- if (budget <= 0) {
- continue;
- }
- const line = lines[i];
- let stripped = 0;
- while (stripped < budget && stripped < line.length) {
- const ch = line[stripped];
- if (ch !== ' ' && ch !== '\t') {
- break;
- }
- stripped += 1;
- }
- lines[i] = line.slice(stripped);
- }
- return lines.join('\n');
-};
-
-// Mirror of `stripLeadingPerLine` that returns *what was stripped* per
-// line, joined with `\n`. Used by `cut` to re-insert the gutter
-// whitespace at the selection location so cut is lossless: the
-// clipboard payload omits the clipped indent gutter, but the underlying
-// document keeps it.
-const extractLeadingPerLine = (text: string, firstLineCount: number, restCount: number): string => {
- const lines = text.split('\n');
- const prefixes: string[] = [];
- for (let i = 0; i < lines.length; i += 1) {
- const budget = i === 0 ? firstLineCount : restCount;
- if (budget <= 0) {
- prefixes.push('');
- continue;
- }
- const line = lines[i];
- let stripped = 0;
- while (stripped < budget && stripped < line.length) {
- const ch = line[stripped];
- if (ch !== ' ' && ch !== '\t') {
- break;
- }
- stripped += 1;
- }
- prefixes.push(line.slice(0, stripped));
- }
- return prefixes.join('\n');
-};
-
-// DOM-aware version of `stripLeadingPerLine`: walks every text node under
-// `root` in document order and removes leading whitespace at the start of
-// each logical line. The budget refills to `restCount` after every `\n`
-// and is consumed across consecutive text nodes so that indent nested
-// inside multiple wrapper spans is still removed correctly.
-const stripLeadingPerLineDom = (root: Node, firstLineCount: number, restCount: number): void => {
- const ownerDoc = root.ownerDocument ?? document;
- const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
- let atLineStart = firstLineCount > 0;
- let remaining = firstLineCount;
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- let result = '';
- for (let i = 0; i < text.length; i += 1) {
- const ch = text[i];
- if (ch === '\n') {
- atLineStart = restCount > 0;
- remaining = restCount;
- result += ch;
- } else if (atLineStart && remaining > 0 && (ch === ' ' || ch === '\t')) {
- remaining -= 1;
- } else {
- atLineStart = false;
- result += ch;
- }
- }
- node.textContent = result;
- }
-};
-
-const toString = (element: HTMLElement): string => {
- const content = element.textContent || '';
-
- // contenteditable Quirk: Without plaintext-only a pre/pre-wrap element must always
- // end with at least one newline character
- if (content[content.length - 1] !== '\n') {
- return `${content}\n`;
- }
-
- return content;
-};
-
-interface LineInfo {
- /** Full text of the requested line. */
- currentLine: string;
- /** Full text of `lineIndex - 1`. Empty when `lineIndex <= 0`. */
- prevLine: string;
- /** Full text of `lineIndex + 1`. Empty when there is no next line. */
- nextLine: string;
- /**
- * True when a real line follows `currentLine` — including a blank
- * line. False when the document ends at `currentLine` (matching the
- * old `toString(element).split('\n').slice(0, -1)` semantics where
- * the phantom empty entry after the trailing `\n` does not count as
- * a next line).
- */
- hasNextLine: boolean;
-}
-
-/**
- * Walk text nodes to extract the requested line plus its immediate
- * neighbors without materializing the full document text or splitting
- * it into a per-line array. Used by per-keystroke handlers (arrow keys,
- * Backspace, gutter snapping) so they stay O(chars-on-touched-lines)
- * instead of O(document-length) on every event.
- *
- * Walks each text node in document order and slices contiguous segments
- * directly into the relevant accumulator (`prevLine` / `currentLine` /
- * `nextLine`). Skips chunks belonging to lines we don't care about and
- * exits as soon as the trailing `\n` of `lineIndex + 1` is consumed.
- *
- * Mirrors `toString(element).split('\n').slice(0, -1)` semantics:
- *
- * - `hasNextLine` is `true` whenever a real line follows `currentLine`,
- * even if that line is blank — `"a\n\nb\n"` reports a next line for
- * row 0. The phantom empty entry that `split` produces after the
- * document's trailing `\n` is intentionally ignored.
- * - The implicit trailing newline that `toString` appends when the DOM
- * doesn't end with one has no effect: we walk raw text content.
- */
-const getLineInfo = (element: HTMLElement, lineIndex: number): LineInfo => {
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let currentLine = '';
- let prevLine = '';
- let nextLine = '';
- let hasNextLine = false;
- let line = 0;
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- let segStart = 0;
- for (let i = 0; i < text.length; i += 1) {
- if (text[i] !== '\n') {
- continue;
- }
- // Flush the segment that lives on `line` before crossing the newline.
- if (segStart < i) {
- const segment = text.slice(segStart, i);
- if (line === lineIndex - 1) {
- prevLine += segment;
- } else if (line === lineIndex) {
- currentLine += segment;
- } else if (line === lineIndex + 1) {
- nextLine += segment;
- }
- }
- // We're about to cross the `\n` that terminates `line`. If `line`
- // is the next line, we've now fully read it and confirmed it
- // exists (a terminator means there is at least one more position
- // in the document past `currentLine`'s end).
- if (line === lineIndex + 1) {
- hasNextLine = true;
- return { currentLine, prevLine, nextLine, hasNextLine };
- }
- line += 1;
- segStart = i + 1;
- }
- // Tail segment of this text node belongs to `line` (no newline yet).
- if (segStart < text.length) {
- const segment = text.slice(segStart);
- if (line === lineIndex - 1) {
- prevLine += segment;
- } else if (line === lineIndex) {
- currentLine += segment;
- } else if (line === lineIndex + 1) {
- // An unterminated tail on `lineIndex + 1` is the document's
- // last (real) line — it counts as a next line. The phantom
- // empty entry produced by `toString`'s trailing `\n` has no
- // tail, so it correctly leaves `hasNextLine` false.
- nextLine += segment;
- hasNextLine = true;
- }
- }
- }
- return { currentLine, prevLine, nextLine, hasNextLine };
-};
-
-/**
- * Convert a `(row, column)` coordinate into an absolute character offset
- * by counting newlines through the editable's text nodes, exiting the
- * moment we land on the requested row. Avoids the
- * `toString(element).split('\n').slice(0, row).join('\n').length`
- * round-trip — that pattern allocates the full document string and a
- * full per-line array on every `edit.move({row, column})` call.
- *
- * If the row is past the end of the document, returns the document
- * length plus `column` so the eventual `makeRange` clamps gracefully.
- */
-const getOffsetAtLineColumn = (element: HTMLElement, row: number, column: number): number => {
- if (row <= 0) {
- return Math.max(0, column);
- }
- let offset = 0;
- let line = 0;
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- for (let i = 0; i < text.length; i += 1) {
- offset += 1;
- if (text[i] === '\n') {
- line += 1;
- if (line === row) {
- return offset + column;
- }
- }
- }
- }
- return offset + column;
-};
-
-const repairUnexpectedLineMerge = (
- newContent: string,
- previousContent: string | null,
- position: Position,
-): string => {
- if (previousContent == null || position.extent !== 0) {
- return newContent;
- }
-
- const previousLines = previousContent.split('\n');
- const nextLines = newContent.split('\n');
-
- if (nextLines.length >= previousLines.length) {
- return newContent;
- }
-
- const cursorLine = position.line;
-
- for (let i = 0; i < cursorLine && i < nextLines.length; i += 1) {
- if (nextLines[i] !== previousLines[i]) {
- return newContent;
- }
- }
-
- const linesLost = previousLines.length - nextLines.length;
- const mergedPreviousContent = previousLines
- .slice(cursorLine + 1, cursorLine + 1 + linesLost)
- .join('');
-
- if (!nextLines[cursorLine]?.endsWith(mergedPreviousContent)) {
- return newContent;
- }
-
- const editedCursorLine = nextLines[cursorLine].slice(
- 0,
- nextLines[cursorLine].length - mergedPreviousContent.length,
- );
-
- if (editedCursorLine === previousLines[cursorLine]) {
- return newContent;
- }
-
- return [
- ...nextLines.slice(0, cursorLine),
- editedCursorLine,
- ...previousLines.slice(cursorLine + 1, cursorLine + 1 + linesLost),
- ...nextLines.slice(cursorLine + 1),
- ].join('\n');
-};
-
-const setStart = (range: Range, node: Node, offset: number) => {
- const length = (node.textContent ?? '').length;
- if (offset < length) {
- range.setStart(node, offset);
- } else {
- range.setStartAfter(node);
- }
-};
-
-const setEnd = (range: Range, node: Node, offset: number) => {
- const length = (node.textContent ?? '').length;
- if (offset < length) {
- range.setEnd(node, offset);
- } else {
- range.setEndAfter(node);
- }
-};
-
-const getPosition = (element: HTMLElement): Position => {
- const range = getCurrentRange();
- const extent = !range.collapsed ? range.toString().length : 0;
-
- // Fast path: cursor is in a text node (Chrome/Safari with plaintext-only, and
- // Firefox after edit.insert repositions the cursor). Walk text nodes to count
- // characters without allocating an O(cursor-position) string.
- if (range.startContainer.nodeType === Node.TEXT_NODE) {
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let position = 0;
- let line = 0;
- let lineContent = '';
-
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const text = node.textContent ?? '';
- const isTarget = node === range.startContainer;
- const upTo = isTarget ? range.startOffset : text.length;
-
- let segStart = 0;
- for (let i = 0; i < upTo; i += 1) {
- if (text[i] === '\n') {
- line += 1;
- lineContent = '';
- segStart = i + 1;
- }
- }
- lineContent += text.slice(segStart, upTo);
- position += upTo;
-
- if (isTarget) {
- break;
- }
- }
-
- return { position, extent, content: lineContent, line };
- }
-
- // Firefox fallback: cursor may be at an element boundary (e.g. after a click
- // before any edit). Use Range.toString() to extract the pre-cursor text.
- // Firefox Quirk: Since plaintext-only is unsupported, the selection can land
- // on element nodes rather than text nodes.
- const untilRange = document.createRange();
- untilRange.setStart(element, 0);
- untilRange.setEnd(range.startContainer, range.startOffset);
- let content = untilRange.toString();
- const position = content.length;
- const lines = content.split('\n');
- const line = lines.length - 1;
- content = lines[line];
- return { position, extent, content, line };
-};
-
-const makeRange = (element: HTMLElement, start: number, end?: number): Range => {
- if (start <= 0) {
- start = 0;
- }
- if (!end || end < 0) {
- end = start;
- }
-
- const range = document.createRange();
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
- let current = 0;
- let position = start;
-
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const length = (node.textContent ?? '').length;
- if (current + length >= position) {
- const offset = position - current;
- if (position === start) {
- setStart(range, node, offset);
- if (end === start) {
- break;
- }
- position = end;
- if (current + length >= position) {
- setEnd(range, node, position - current);
- break;
- }
- // end is in a later node — fall through to advance current
- } else {
- setEnd(range, node, offset);
- break;
- }
- }
- current += length;
- }
-
- return range;
-};
-
-/** Walk to the next text node in document order without allocating a TreeWalker. */
-const nextTextNode = (node: Node): Node | null => {
- let current: Node | null = node;
- // Walk up and across siblings until we find a branch to descend into.
- while (current) {
- if (current.nextSibling) {
- current = current.nextSibling;
- // Descend to the first text node.
- while (current.firstChild) {
- current = current.firstChild;
- }
- if (current.nodeType === Node.TEXT_NODE) {
- return current;
- }
- // Not a text leaf — continue walking siblings from here.
- continue;
- }
- current = current.parentNode;
- }
- return null;
-};
-
-/**
- * After makeRange positions a collapsed cursor at a newline boundary via
- * setStartAfter(textNode), the cursor ends up inside the *previous* line span
- * (after the '\n'). This adjusts the range forward to offset 0 of the
- * next text node so the cursor renders on the correct visual line.
- */
-const adjustCursorAtNewlineBoundary = (range: Range): void => {
- if (!range.collapsed) {
- return;
- }
-
- const { startContainer, startOffset } = range;
- const startText = startContainer.textContent ?? '';
-
- // Case 1: cursor is in a text node at the very end and that text ends with '\n'
- if (
- startContainer.nodeType === Node.TEXT_NODE &&
- startOffset === startText.length &&
- startText.endsWith('\n')
- ) {
- const next = nextTextNode(startContainer);
- if (next) {
- range.setStart(next, 0);
- range.collapse(true);
- }
- return;
- }
-
- // Case 2: cursor is at an element boundary where the previous child is a
- // text node ending with '\n' (happens when setStartAfter places us here)
- if (startContainer.nodeType === Node.ELEMENT_NODE && startOffset > 0) {
- const prevChild = startContainer.childNodes[startOffset - 1];
- const prevText = prevChild?.textContent ?? '';
- if (prevChild?.nodeType === Node.TEXT_NODE && prevText.endsWith('\n')) {
- const next = nextTextNode(prevChild);
- if (next) {
- range.setStart(next, 0);
- range.collapse(true);
- }
- }
- }
-};
-
interface State {
disconnected: boolean;
onChange(text: string, position: Position): void;
@@ -1625,139 +1144,28 @@ export const useEditable = (
const startColumn = beforeText.length - (lastNewline + 1);
firstLineStrip = Math.max(0, minColumn - startColumn);
}
- let plainText = range.toString();
- if (restStrip > 0) {
- // The caret-navigation guard already treats `[0, minColumn)` as a
- // clipped indent gutter. Strip up to that many leading whitespace
- // characters per line from the clipboard so the pasted snippet
- // matches what the user sees rather than including indent that
- // is hidden in the editable.
- plainText = stripLeadingPerLine(plainText, firstLineStrip, restStrip);
- }
+
+ // The caret-navigation guard already treats `[0, minColumn)` as a
+ // clipped indent gutter. Strip up to that many leading whitespace
+ // characters per line from the clipboard so the pasted snippet
+ // matches what the user sees rather than including indent that
+ // is hidden in the editable.
+ const plainText =
+ restStrip > 0
+ ? stripLeadingPerLine(range.toString(), firstLineStrip, restStrip)
+ : range.toString();
event.clipboardData.setData('text/plain', plainText);
- // Re-serialize the HTML ourselves since `preventDefault()` skipped
- // the browser's default text/html write. Wrap in a `
` so the
- // monospace + whitespace context survives, then inline the
- // computed styles from each source element onto its clone so
- // rich-text paste targets (email, Word, Notion, etc.) render with
- // the same visual styling without needing our stylesheet.
- const doc = element.ownerDocument;
- const view = doc.defaultView;
- const fragment = range.cloneContents();
- const container = doc.createElement('pre');
- // Carry the editable's className onto the wrapper so consumers
- // that scope styles by class (e.g. `.code-block`) keep matching
- // when the snippet is pasted into a richer environment that loads
- // the same stylesheet.
- if (element.className) {
- container.className = element.className;
- }
- // `Range.cloneContents` returns the descendants of the
- // `commonAncestorContainer` but never the ancestor itself, so any
- // selection that lives entirely inside a styled wrapper (a single
- // text node inside a token, or multiple children of the same token)
- // loses that wrapper in the clipboard payload. The computed-style
- // inlining pass below has nothing to inline onto in that case.
- // Reconstruct the ancestor chain up to (but not including) the
- // editable root and inline styles onto each rebuilt wrapper so
- // rich-text paste targets keep the original highlighting.
- const cac = range.commonAncestorContainer;
- const anchor: Element | null = asElement(cac) ?? cac.parentElement;
- let rootContent: Node = fragment;
- // The innermost reconstructed wrapper, if any. The style-inlining
- // pass below walks from here so the clone walker stays aligned
- // with the source walker (which starts from the CAC's descendants).
- let cloneStylingRoot: Node = container;
- if (anchor && anchor !== element && element.contains(anchor)) {
- let current: Element | null = anchor;
- let innermost: Element | null = null;
- while (current && current !== element) {
- const cloned = current.cloneNode(false);
- // `Element.cloneNode` returns an Element; the runtime check
- // exists purely to satisfy the DOM lib's `Node` return type.
- if (!(cloned instanceof Element)) {
- current = current.parentElement;
- continue;
- }
- const ancestorClone = cloned;
- if (view) {
- const computed = view.getComputedStyle(current);
- let inline = ancestorClone.getAttribute('style') ?? '';
- for (const prop of CLIPBOARD_STYLE_PROPS) {
- const value = computed.getPropertyValue(prop);
- if (value && value !== 'normal' && value !== 'none' && value !== 'auto') {
- inline += `${prop}:${value};`;
- }
- }
- if (inline) {
- ancestorClone.setAttribute('style', inline);
- }
- }
- ancestorClone.appendChild(rootContent);
- rootContent = ancestorClone;
- if (innermost === null) {
- innermost = ancestorClone;
- }
- current = current.parentElement;
- }
- if (innermost) {
- cloneStylingRoot = innermost;
- }
- }
- container.appendChild(rootContent);
- if (view) {
- // Walk the CAC's descendants and mirror them onto the cloned
- // descendants of the innermost reconstructed wrapper. Both
- // walkers exclude their root, so as long as the roots correspond
- // (CAC ↔ innermost reconstructed wrapper, or CAC ↔
when
- // there is no reconstruction) the per-step pairing is correct.
- const sourceWalker = doc.createTreeWalker(
- range.commonAncestorContainer,
- NodeFilter.SHOW_ELEMENT,
- {
- acceptNode: (node) =>
- range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
- },
- );
- const cloneWalker = doc.createTreeWalker(cloneStylingRoot, NodeFilter.SHOW_ELEMENT);
- let source = nextElement(sourceWalker);
- let clone = nextElement(cloneWalker);
- while (source && clone) {
- if (source.tagName === clone.tagName) {
- const computed = view.getComputedStyle(source);
- let inline = clone.getAttribute('style') ?? '';
- for (const prop of CLIPBOARD_STYLE_PROPS) {
- const value = computed.getPropertyValue(prop);
- if (value && value !== 'normal' && value !== 'none' && value !== 'auto') {
- inline += `${prop}:${value};`;
- }
- }
- if (inline) {
- clone.setAttribute('style', inline);
- }
- }
- source = nextElement(sourceWalker);
- clone = nextElement(cloneWalker);
- }
- // Apply the editable's own typography to the wrapper so the
- // pasted block matches the source font/size even when only a
- // descendant span was selected.
- const rootComputed = view.getComputedStyle(element);
- let rootInline = CLIPBOARD_ROOT_STATIC_STYLES;
- for (const prop of CLIPBOARD_ROOT_STYLE_PROPS) {
- const value = rootComputed.getPropertyValue(prop);
- if (value) {
- rootInline += `${prop}:${value};`;
- }
- }
- if (rootInline) {
- container.setAttribute('style', rootInline);
- }
- }
+
+ const container = cloneRangeWithInlineStyles(element, range, {
+ elementStyleProps: CLIPBOARD_ELEMENT_STYLE_PROPS,
+ rootStyleProps: CLIPBOARD_ROOT_STYLE_PROPS,
+ rootStaticStyles: CLIPBOARD_ROOT_STATIC_STYLES,
+ });
if (restStrip > 0) {
stripLeadingPerLineDom(container, firstLineStrip, restStrip);
}
event.clipboardData.setData('text/html', container.outerHTML);
+
if (event.type === 'cut') {
// Mirror the paste path: capture pre-edit state for history, then
// delete the selection. When `minColumn` clipped the leading
diff --git a/packages/docs-infra/src/useCode/useEditableUtils.ts b/packages/docs-infra/src/useCode/useEditableUtils.ts
new file mode 100644
index 000000000..d29337019
--- /dev/null
+++ b/packages/docs-infra/src/useCode/useEditableUtils.ts
@@ -0,0 +1,430 @@
+/*
+ * Pure DOM/text helpers extracted from useEditable.ts. None of these
+ * touch React state or the hook's internal `state` object — they only
+ * read from / mutate the DOM and the browser Selection. Kept in a
+ * sibling file (per AGENTS.md docs-infra rule 2.3) so the main hook
+ * stays focused on lifecycle wiring and event handling.
+ */
+
+export interface Position {
+ position: number;
+ extent: number;
+ content: string;
+ line: number;
+}
+
+export const getCurrentRange = (): Range => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ // Internal helper — only called from event handlers and edit methods
+ // that have already verified there is an active selection. Throwing
+ // here surfaces contract violations early instead of letting them
+ // explode further down the call stack (matching the prior implicit
+ // `DOMException` from `getRangeAt(0)` on an empty selection).
+ throw new Error('useEditable: expected an active selection');
+ }
+ return selection.getRangeAt(0);
+};
+
+export const setCurrentRange = (range: Range) => {
+ const selection = window.getSelection();
+ if (!selection) {
+ return;
+ }
+ selection.empty();
+ selection.addRange(range);
+};
+
+/**
+ * Narrow a `Node | null` to `Element | null` using a runtime check so
+ * downstream code can reason about element-only APIs without a cast.
+ */
+export const asElement = (node: Node | null | undefined): Element | null =>
+ node instanceof Element ? node : null;
+
+/**
+ * Pull the next element out of a `SHOW_ELEMENT` `TreeWalker` with a
+ * runtime check rather than a type cast. Tree walkers configured for
+ * `SHOW_ELEMENT` only emit elements in practice, but the DOM type
+ * exposes `Node | null`.
+ */
+export const nextElement = (walker: TreeWalker): Element | null => asElement(walker.nextNode());
+
+export const isUndoRedoKey = (event: KeyboardEvent): boolean =>
+ (event.metaKey || event.ctrlKey) && !event.altKey && event.code === 'KeyZ';
+
+export const isPlaintextInputKey = (event: KeyboardEvent): boolean => {
+ const usesAltGraph =
+ typeof event.getModifierState === 'function' && event.getModifierState('AltGraph');
+
+ return (
+ event.key.length === 1 && !event.metaKey && !event.ctrlKey && (!event.altKey || usesAltGraph)
+ );
+};
+
+export const toString = (element: HTMLElement): string => {
+ const content = element.textContent || '';
+
+ // contenteditable Quirk: Without plaintext-only a pre/pre-wrap element must always
+ // end with at least one newline character
+ if (content[content.length - 1] !== '\n') {
+ return `${content}\n`;
+ }
+
+ return content;
+};
+
+export interface LineInfo {
+ /** Full text of the requested line. */
+ currentLine: string;
+ /** Full text of `lineIndex - 1`. Empty when `lineIndex <= 0`. */
+ prevLine: string;
+ /** Full text of `lineIndex + 1`. Empty when there is no next line. */
+ nextLine: string;
+ /**
+ * True when a real line follows `currentLine` — including a blank
+ * line. False when the document ends at `currentLine` (matching the
+ * old `toString(element).split('\n').slice(0, -1)` semantics where
+ * the phantom empty entry after the trailing `\n` does not count as
+ * a next line).
+ */
+ hasNextLine: boolean;
+}
+
+/**
+ * Walk text nodes to extract the requested line plus its immediate
+ * neighbors without materializing the full document text or splitting
+ * it into a per-line array. Used by per-keystroke handlers (arrow keys,
+ * Backspace, gutter snapping) so they stay O(chars-on-touched-lines)
+ * instead of O(document-length) on every event.
+ *
+ * Walks each text node in document order and slices contiguous segments
+ * directly into the relevant accumulator (`prevLine` / `currentLine` /
+ * `nextLine`). Skips chunks belonging to lines we don't care about and
+ * exits as soon as the trailing `\n` of `lineIndex + 1` is consumed.
+ *
+ * Mirrors `toString(element).split('\n').slice(0, -1)` semantics:
+ *
+ * - `hasNextLine` is `true` whenever a real line follows `currentLine`,
+ * even if that line is blank — `"a\n\nb\n"` reports a next line for
+ * row 0. The phantom empty entry that `split` produces after the
+ * document's trailing `\n` is intentionally ignored.
+ * - The implicit trailing newline that `toString` appends when the DOM
+ * doesn't end with one has no effect: we walk raw text content.
+ */
+export const getLineInfo = (element: HTMLElement, lineIndex: number): LineInfo => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let currentLine = '';
+ let prevLine = '';
+ let nextLine = '';
+ let hasNextLine = false;
+ let line = 0;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? '';
+ let segStart = 0;
+ for (let i = 0; i < text.length; i += 1) {
+ if (text[i] !== '\n') {
+ continue;
+ }
+ // Flush the segment that lives on `line` before crossing the newline.
+ if (segStart < i) {
+ const segment = text.slice(segStart, i);
+ if (line === lineIndex - 1) {
+ prevLine += segment;
+ } else if (line === lineIndex) {
+ currentLine += segment;
+ } else if (line === lineIndex + 1) {
+ nextLine += segment;
+ }
+ }
+ // We're about to cross the `\n` that terminates `line`. If `line`
+ // is the next line, we've now fully read it and confirmed it
+ // exists (a terminator means there is at least one more position
+ // in the document past `currentLine`'s end).
+ if (line === lineIndex + 1) {
+ hasNextLine = true;
+ return { currentLine, prevLine, nextLine, hasNextLine };
+ }
+ line += 1;
+ segStart = i + 1;
+ }
+ // Tail segment of this text node belongs to `line` (no newline yet).
+ if (segStart < text.length) {
+ const segment = text.slice(segStart);
+ if (line === lineIndex - 1) {
+ prevLine += segment;
+ } else if (line === lineIndex) {
+ currentLine += segment;
+ } else if (line === lineIndex + 1) {
+ // An unterminated tail on `lineIndex + 1` is the document's
+ // last (real) line — it counts as a next line. The phantom
+ // empty entry produced by `toString`'s trailing `\n` has no
+ // tail, so it correctly leaves `hasNextLine` false.
+ nextLine += segment;
+ hasNextLine = true;
+ }
+ }
+ }
+ return { currentLine, prevLine, nextLine, hasNextLine };
+};
+
+/**
+ * Convert a `(row, column)` coordinate into an absolute character offset
+ * by counting newlines through the editable's text nodes, exiting the
+ * moment we land on the requested row. Avoids the
+ * `toString(element).split('\n').slice(0, row).join('\n').length`
+ * round-trip — that pattern allocates the full document string and a
+ * full per-line array on every `edit.move({row, column})` call.
+ *
+ * If the row is past the end of the document, returns the document
+ * length plus `column` so the eventual `makeRange` clamps gracefully.
+ */
+export const getOffsetAtLineColumn = (
+ element: HTMLElement,
+ row: number,
+ column: number,
+): number => {
+ if (row <= 0) {
+ return Math.max(0, column);
+ }
+ let offset = 0;
+ let line = 0;
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? '';
+ for (let i = 0; i < text.length; i += 1) {
+ offset += 1;
+ if (text[i] === '\n') {
+ line += 1;
+ if (line === row) {
+ return offset + column;
+ }
+ }
+ }
+ }
+ return offset + column;
+};
+
+export const repairUnexpectedLineMerge = (
+ newContent: string,
+ previousContent: string | null,
+ position: Position,
+): string => {
+ if (previousContent == null || position.extent !== 0) {
+ return newContent;
+ }
+
+ const previousLines = previousContent.split('\n');
+ const nextLines = newContent.split('\n');
+
+ if (nextLines.length >= previousLines.length) {
+ return newContent;
+ }
+
+ const cursorLine = position.line;
+
+ for (let i = 0; i < cursorLine && i < nextLines.length; i += 1) {
+ if (nextLines[i] !== previousLines[i]) {
+ return newContent;
+ }
+ }
+
+ const linesLost = previousLines.length - nextLines.length;
+ const mergedPreviousContent = previousLines
+ .slice(cursorLine + 1, cursorLine + 1 + linesLost)
+ .join('');
+
+ if (!nextLines[cursorLine]?.endsWith(mergedPreviousContent)) {
+ return newContent;
+ }
+
+ const editedCursorLine = nextLines[cursorLine].slice(
+ 0,
+ nextLines[cursorLine].length - mergedPreviousContent.length,
+ );
+
+ if (editedCursorLine === previousLines[cursorLine]) {
+ return newContent;
+ }
+
+ return [
+ ...nextLines.slice(0, cursorLine),
+ editedCursorLine,
+ ...previousLines.slice(cursorLine + 1, cursorLine + 1 + linesLost),
+ ...nextLines.slice(cursorLine + 1),
+ ].join('\n');
+};
+
+const setStart = (range: Range, node: Node, offset: number) => {
+ const length = (node.textContent ?? '').length;
+ if (offset < length) {
+ range.setStart(node, offset);
+ } else {
+ range.setStartAfter(node);
+ }
+};
+
+const setEnd = (range: Range, node: Node, offset: number) => {
+ const length = (node.textContent ?? '').length;
+ if (offset < length) {
+ range.setEnd(node, offset);
+ } else {
+ range.setEndAfter(node);
+ }
+};
+
+export const getPosition = (element: HTMLElement): Position => {
+ const range = getCurrentRange();
+ const extent = !range.collapsed ? range.toString().length : 0;
+
+ // Fast path: cursor is in a text node (Chrome/Safari with plaintext-only, and
+ // Firefox after edit.insert repositions the cursor). Walk text nodes to count
+ // characters without allocating an O(cursor-position) string.
+ if (range.startContainer.nodeType === Node.TEXT_NODE) {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let position = 0;
+ let line = 0;
+ let lineContent = '';
+
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? '';
+ const isTarget = node === range.startContainer;
+ const upTo = isTarget ? range.startOffset : text.length;
+
+ let segStart = 0;
+ for (let i = 0; i < upTo; i += 1) {
+ if (text[i] === '\n') {
+ line += 1;
+ lineContent = '';
+ segStart = i + 1;
+ }
+ }
+ lineContent += text.slice(segStart, upTo);
+ position += upTo;
+
+ if (isTarget) {
+ break;
+ }
+ }
+
+ return { position, extent, content: lineContent, line };
+ }
+
+ // Firefox fallback: cursor may be at an element boundary (e.g. after a click
+ // before any edit). Use Range.toString() to extract the pre-cursor text.
+ // Firefox Quirk: Since plaintext-only is unsupported, the selection can land
+ // on element nodes rather than text nodes.
+ const untilRange = document.createRange();
+ untilRange.setStart(element, 0);
+ untilRange.setEnd(range.startContainer, range.startOffset);
+ let content = untilRange.toString();
+ const position = content.length;
+ const lines = content.split('\n');
+ const line = lines.length - 1;
+ content = lines[line];
+ return { position, extent, content, line };
+};
+
+export const makeRange = (element: HTMLElement, start: number, end?: number): Range => {
+ if (start <= 0) {
+ start = 0;
+ }
+ if (!end || end < 0) {
+ end = start;
+ }
+
+ const range = document.createRange();
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let current = 0;
+ let position = start;
+
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const length = (node.textContent ?? '').length;
+ if (current + length >= position) {
+ const offset = position - current;
+ if (position === start) {
+ setStart(range, node, offset);
+ if (end === start) {
+ break;
+ }
+ position = end;
+ if (current + length >= position) {
+ setEnd(range, node, position - current);
+ break;
+ }
+ // end is in a later node — fall through to advance current
+ } else {
+ setEnd(range, node, offset);
+ break;
+ }
+ }
+ current += length;
+ }
+
+ return range;
+};
+
+/** Walk to the next text node in document order without allocating a TreeWalker. */
+const nextTextNode = (node: Node): Node | null => {
+ let current: Node | null = node;
+ // Walk up and across siblings until we find a branch to descend into.
+ while (current) {
+ if (current.nextSibling) {
+ current = current.nextSibling;
+ // Descend to the first text node.
+ while (current.firstChild) {
+ current = current.firstChild;
+ }
+ if (current.nodeType === Node.TEXT_NODE) {
+ return current;
+ }
+ // Not a text leaf — continue walking siblings from here.
+ continue;
+ }
+ current = current.parentNode;
+ }
+ return null;
+};
+
+/**
+ * After makeRange positions a collapsed cursor at a newline boundary via
+ * setStartAfter(textNode), the cursor ends up inside the *previous* line span
+ * (after the '\n'). This adjusts the range forward to offset 0 of the
+ * next text node so the cursor renders on the correct visual line.
+ */
+export const adjustCursorAtNewlineBoundary = (range: Range): void => {
+ if (!range.collapsed) {
+ return;
+ }
+
+ const { startContainer, startOffset } = range;
+ const startText = startContainer.textContent ?? '';
+
+ // Case 1: cursor is in a text node at the very end and that text ends with '\n'
+ if (
+ startContainer.nodeType === Node.TEXT_NODE &&
+ startOffset === startText.length &&
+ startText.endsWith('\n')
+ ) {
+ const next = nextTextNode(startContainer);
+ if (next) {
+ range.setStart(next, 0);
+ range.collapse(true);
+ }
+ return;
+ }
+
+ // Case 2: cursor is at an element boundary where the previous child is a
+ // text node ending with '\n' (happens when setStartAfter places us here)
+ if (startContainer.nodeType === Node.ELEMENT_NODE && startOffset > 0) {
+ const prevChild = startContainer.childNodes[startOffset - 1];
+ const prevText = prevChild?.textContent ?? '';
+ if (prevChild?.nodeType === Node.TEXT_NODE && prevText.endsWith('\n')) {
+ const next = nextTextNode(prevChild);
+ if (next) {
+ range.setStart(next, 0);
+ range.collapse(true);
+ }
+ }
+ }
+};
From 43c0650d56b4c3543a2d4d15784e7d7dc41d677c Mon Sep 17 00:00:00 2001
From: dav-is
Date: Tue, 28 Apr 2026 11:39:55 -0400
Subject: [PATCH 45/45] Update changes list
---
.../docs-infra/src/useCode/useEditable.ts | 29 ++++++++-----------
1 file changed, 12 insertions(+), 17 deletions(-)
diff --git a/packages/docs-infra/src/useCode/useEditable.ts b/packages/docs-infra/src/useCode/useEditable.ts
index 3e514b69a..61f6f669f 100644
--- a/packages/docs-infra/src/useCode/useEditable.ts
+++ b/packages/docs-infra/src/useCode/useEditable.ts
@@ -27,23 +27,18 @@ SOFTWARE.
*/
// Forked from https://github.com/FormidableLabs/use-editable
-// Changes:
-// - Fix linting and formatting
-// - Add Tests
-// - Replace manual queue-based DFS in makeRange with TreeWalker for better performance
-// - Replace Range.toString() in getPosition with a TreeWalker character count to avoid O(N) string allocation
-// - Deduplicate toString() calls via trackState return value
-// - Fix Firefox rapid-typing line-loss bug: preserve pre-edit pendingContent across keydowns until flush
-// - Refresh pendingContent baseline after controlled edits so native input following Enter/Tab/Backspace can still be repaired
-// - Record repaired (not raw) content into the undo stack so Firefox merge intermediates don't pollute history
-// - Debounce repeat-key flushes so highlights only re-render once the user pauses typing
-// - Fix undo-to-initial-state bug: allow trackState to record before the first flushChanges
-// - Fix undo-after-rapid-Enter bug: bypass 500ms dedup on keyup for structural edits (Enter)
-// - Fix React 19 compatibility: useState lazy init for edit, useRef for MutationObserver, window SSR guard
-// - Add `minColumn` option: skip clipped indent gutter via horizontal arrow navigation
-// - Add `minRow`/`maxRow`/`onBoundary` options: detect arrow-key navigation past the visible region; allow native movement when `onBoundary` is provided so hosts can expand collapsed regions without losing focus
-// - Add `caretSelector` option: when the caret is inside a matching element, `ArrowLeft` at column 0 and `ArrowRight` at the end of a line jump synchronously to the adjacent line so non-selectable gap text nodes (e.g. newlines between `.line` spans) don't trap the caret. Vertical navigation is left to the browser to preserve wrapped-line behavior in `pre-wrap` layouts
-// - Override `copy`/`cut` to write `Range.toString()` for `text/plain` (avoiding duplicated newlines from block-level line wrappers like `display: block` `.line` spans separated by literal `\n` text nodes) and a `
`-wrapped clone with computed styles inlined for `text/html` so pasting into rich-text targets (email, Word, Notion, etc.) keeps syntax highlighting without depending on the host stylesheet. When `minColumn` is set, also strips up to that many leading whitespace characters per line from both payloads so the clipped indent gutter doesn't leak into the clipboard
+// Changes (see git history and inline comments for rationale):
+// - Linting, formatting, tests, and React 19 compatibility (lazy useState, useRef MutationObserver, SSR guards)
+// - Performance: TreeWalker-based makeRange/getPosition, deduped toString() calls, getLineInfo walks only neighboring lines
+// - Firefox quirks: preserve pendingContent across rapid keydowns, refresh baseline after controlled edits, repair line-merges, route plaintext keys through edit.insert in the contentEditable="true" fallback
+// - Undo stack: record repaired (not raw) content, allow tracking before first flush, bypass 500ms dedup for structural edits (Enter)
+// - Repeat-key flush debouncing so syntax re-highlight fires once on key release
+// - Resync (instead of block) on stale-DOM arrow keys so navigation isn't eaten after a pending edit
+// - adjustCursorAtNewlineBoundary applied to all programmatic caret placements; getState() returns an empty snapshot pre-mount
+// - New `minColumn` option: skip clipped indent gutter via arrow navigation, click, and tab-focus; Backspace on a fully-clipped blank line collapses the line
+// - New `minRow`/`maxRow`/`onBoundary` options: arrow navigation past the visible region invokes the callback (and falls through natively when provided so hosts can expand collapsed regions)
+// - New `caretSelector` option: synchronous horizontal line-wrap and post-arrow rAF snap to lift the caret out of inter-line gap text nodes (e.g. `\n` between `.line` spans)
+// - Override copy/cut: write `Range.toString()` for `text/plain` (avoids duplicated newlines from block-level line wrappers) and an inline-styled `
` clone for `text/html`; strip the clipped indent gutter from both payloads when `minColumn` is set
import * as React from 'react';