mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
Wrap long code lines instead of scrolling sideways (#219)
Code files in the viewer scrolled horizontally for any line wider than
the pane. Wrap them instead, in both the read-only view and the editor.
Wrapping breaks the old gutter's assumption that source line N is visual
row N, so each line becomes its own row carrying its own number:
highlightLines() splits the refractor tree per source line, reopening
tokens that straddle a newline.
In the editor the overlay's numbers are absolutely positioned out of the
line box — an in-flow number is an atomic inline offering a break
opportunity the textarea lacks, which desynced the two layers on long
URLs and base64. Every code element also spells out its font: Preflight
isn't imported, so the UA `pre, code { font-family: monospace }` beats an
inherited font and would give the layers different fonts and `ch` units.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d16359ce19
commit
38e9943df6
Vendored
+1034
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -26,8 +26,8 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-CFPQ5Tuj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-wpJeURKR.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bx_pvhOP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_dWCzRy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Layout the read-only code view and the editor's highlighted overlay must
|
||||
// agree on: the two render the same file side by side (the overlay sits under a
|
||||
// transparent textarea), so any divergence in font metrics, wrapping or gutter
|
||||
// width shows up as text that doesn't line up.
|
||||
|
||||
// Preflight isn't imported (see tailwind.css), so the UA's
|
||||
// `pre, code { font-family: monospace }` beats an inherited font — every code
|
||||
// element needs these spelled out rather than relying on an ancestor.
|
||||
export const CODE_TEXT_CLASS_NAME = "font-mono text-sm leading-[1.55] [tab-size:4]";
|
||||
|
||||
export const CODE_WRAP_CLASS_NAME = "whitespace-pre-wrap break-words";
|
||||
|
||||
export const CODE_GUTTER_CLASS_NAME = "file-view-gutter text-right text-muted select-none";
|
||||
|
||||
/** Column positions in `ch`, so they track the mono font: the rule sits at
|
||||
* `ruleCh` (the number is right-aligned just inside it), code starts at
|
||||
* `codeCh` — 2ch past the rule, which is where both views pad their code. Only
|
||||
* meaningful on an element with CODE_TEXT_CLASS_NAME. */
|
||||
export function codeGutter(lineCount: number): { ruleCh: number; codeCh: number } {
|
||||
const ruleCh = String(lineCount).length + 2;
|
||||
// The 2ch gap is spelled `pl-[2ch]` on CodeView's code column.
|
||||
return { ruleCh, codeCh: ruleCh + 2 };
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
// Inline source editor: a transparent <textarea> layered over the same
|
||||
// refractor-highlighted <pre> + gutter that CodeView renders read-only, so
|
||||
// syntax colors stay live while typing. It IS the view for editable files —
|
||||
// there's no separate mode, you just click and type. The textarea owns input,
|
||||
// caret and selection; the highlighted pre and gutter are scroll-synced to it.
|
||||
// refractor-highlighted lines CodeView renders read-only, so syntax colors stay
|
||||
// live while typing. It IS the view for editable files — there's no separate
|
||||
// mode, you just click and type. The textarea owns input, caret and selection;
|
||||
// the highlighted overlay is scroll-synced to it. Line numbers are absolutely
|
||||
// positioned out of the overlay's line boxes: anything in flow there is a wrap
|
||||
// opportunity the textarea doesn't have, which desyncs the two layers.
|
||||
// Token colors apply under a `.file-view` ancestor (see CodeView).
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
CODE_GUTTER_CLASS_NAME,
|
||||
CODE_TEXT_CLASS_NAME,
|
||||
CODE_WRAP_CLASS_NAME,
|
||||
codeGutter,
|
||||
} from "../codeLayout";
|
||||
import { detectSyntaxLanguageFromFilePath } from "../syntaxLanguage";
|
||||
import { highlight } from "../syntaxHighlight";
|
||||
import { highlightLines, isBlankLine } from "../syntaxHighlight";
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
@@ -32,46 +40,42 @@ export function CodeEditor({
|
||||
scrollRequest?: number;
|
||||
onScrollRequestHandled?: () => void;
|
||||
}) {
|
||||
const rendered = useMemo(
|
||||
() => highlight(value, detectSyntaxLanguageFromFilePath(path)),
|
||||
// A trailing newline opens a new (empty) line the caret can sit on, so unlike
|
||||
// the read-only view every "\n" gets a row.
|
||||
const lines = useMemo(
|
||||
() => highlightLines(value, detectSyntaxLanguageFromFilePath(path)),
|
||||
[value, path],
|
||||
);
|
||||
// A trailing newline opens a new (empty) line the caret can sit on, so unlike
|
||||
// the read-only view every "\n" counts toward the gutter.
|
||||
const lineCount = value ? value.split("\n").length : 1;
|
||||
const { ruleCh, codeCh } = codeGutter(lines.length);
|
||||
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const gutterRef = useRef<HTMLPreElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the highlighted layer and the gutter pinned to the textarea's scroll.
|
||||
// Keep the highlighted layer pinned to the textarea's scroll.
|
||||
const syncScroll = () => {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = ta.scrollTop;
|
||||
preRef.current.scrollLeft = ta.scrollLeft;
|
||||
}
|
||||
if (gutterRef.current) gutterRef.current.scrollTop = ta.scrollTop;
|
||||
if (ta && overlayRef.current) overlayRef.current.scrollTop = ta.scrollTop;
|
||||
};
|
||||
// Re-sync after content changes relayout (e.g. a newline shifts scrollHeight).
|
||||
useLayoutEffect(syncScroll, [value]);
|
||||
|
||||
// On open via a `file:line` chip, park the caret on that line and center it.
|
||||
// Re-runs when the file changes (path) or a new chip targets the open file
|
||||
// (scrollRequest), mirroring CodeView's re-navigation.
|
||||
// (scrollRequest) — which is also what makes it land: the first run sees the
|
||||
// draft before FileViewer's passive effect has seeded it, and clearing the
|
||||
// request re-runs this against real content.
|
||||
useLayoutEffect(() => {
|
||||
const ta = taRef.current;
|
||||
if (!ta || !highlightLine) return;
|
||||
const cs = getComputedStyle(ta);
|
||||
const padTop = Number.parseFloat(cs.paddingTop) || 0;
|
||||
const lineH = Number.parseFloat(cs.lineHeight) || 0;
|
||||
const target = Math.min(Math.max(Math.trunc(highlightLine), 1), lineCount);
|
||||
const lines = value.split("\n");
|
||||
const text = value.split("\n");
|
||||
const target = Math.min(Math.max(Math.trunc(highlightLine), 1), text.length);
|
||||
let caret = 0;
|
||||
for (let i = 0; i < target - 1; i++) caret += lines[i].length + 1;
|
||||
for (let i = 0; i < target - 1; i++) caret += text[i].length + 1;
|
||||
ta.setSelectionRange(caret, caret);
|
||||
if (lineH) ta.scrollTop = Math.max(0, padTop + (target - 1) * lineH - ta.clientHeight / 2);
|
||||
// The overlay wraps exactly like the textarea, so its row is where the line
|
||||
// actually sits — line-height arithmetic would miss by every wrap.
|
||||
const row = overlayRef.current?.querySelector<HTMLElement>(`[data-line="${target}"]`);
|
||||
if (row) ta.scrollTop = Math.max(0, row.offsetTop - ta.clientHeight / 2);
|
||||
syncScroll();
|
||||
onScrollRequestHandled?.();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -96,38 +100,55 @@ export function CodeEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Both layers must reserve the scrollbar, or the textarea is narrower than the
|
||||
// overlay on classic-scrollbar platforms and the two wrap at different columns.
|
||||
const layerClassName = `absolute inset-0 m-0 py-3.5 pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME} [scrollbar-gutter:stable]`;
|
||||
|
||||
return (
|
||||
<div className="file-view-editwrap flex items-stretch h-full min-h-0 relative">
|
||||
<pre
|
||||
ref={gutterRef}
|
||||
className="file-view-gutter m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-3.5 pr-2.5 text-right text-muted select-none bg-background border-r border-r-border-variant shrink-0 overflow-hidden"
|
||||
<div className={`file-view-editwrap relative h-full min-h-0 ${CODE_TEXT_CLASS_NAME}`}>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
|
||||
style={{ width: `${ruleCh}ch` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={`file-view-code ${layerClassName} overflow-hidden pointer-events-none`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => i + 1).join("\n")}
|
||||
</pre>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="file-view-code absolute inset-0 m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] whitespace-pre overflow-hidden pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<code>{rendered}</code>
|
||||
</pre>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className="file-view-editarea absolute inset-0 m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] whitespace-pre overflow-auto resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
wrap="off"
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
/* The scroll target for `file:line` — measured, so it's on the row. */
|
||||
data-line={i + 1}
|
||||
className="relative"
|
||||
style={{ paddingLeft: `${codeCh}ch` }}
|
||||
>
|
||||
<span
|
||||
className={`${CODE_GUTTER_CLASS_NAME} absolute left-0 pr-[1ch]`}
|
||||
style={{ width: `${ruleCh}ch` }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Out-of-flow numbers leave a blank line with no line box at all. */}
|
||||
{isBlankLine(line) ? <br /> : line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className={`file-view-editarea ${layerClassName} overflow-y-auto overflow-x-hidden resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none`}
|
||||
style={{ paddingLeft: `${codeCh}ch` }}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// Shared source-code block: line-number gutter + refractor-highlighted
|
||||
// content, used by the repo file viewer and the Artifacts tab preview. Style
|
||||
// scoping note: syntax token colors apply under a `.file-view` ancestor.
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
CODE_GUTTER_CLASS_NAME,
|
||||
CODE_TEXT_CLASS_NAME,
|
||||
CODE_WRAP_CLASS_NAME,
|
||||
codeGutter,
|
||||
} from "../codeLayout";
|
||||
import { detectSyntaxLanguageFromFilePath } from "../syntaxLanguage";
|
||||
import { highlight } from "../syntaxHighlight";
|
||||
import { highlightLines, isBlankLine } from "../syntaxHighlight";
|
||||
|
||||
export function CodeView({
|
||||
text,
|
||||
@@ -19,72 +25,82 @@ export function CodeView({
|
||||
scrollRequest?: number;
|
||||
onScrollRequestHandled?: () => void;
|
||||
}) {
|
||||
const rendered = useMemo(
|
||||
() => highlight(text, detectSyntaxLanguageFromFilePath(path)),
|
||||
[text, path],
|
||||
);
|
||||
// One number per source line; a trailing newline ends a line, it doesn't
|
||||
// start an empty one.
|
||||
const lineCount = text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0;
|
||||
const targetLine = highlightLine && lineCount > 0
|
||||
? Math.min(Math.max(Math.trunc(highlightLine), 1), lineCount)
|
||||
: undefined;
|
||||
// A trailing newline ends a line, it doesn't start an empty one. Empty files
|
||||
// render no rows at all — a lone gutter is just a stray bordered strip.
|
||||
const lines = useMemo(() => {
|
||||
if (!text) return [];
|
||||
// CR is a segment break under pre-wrap, so any CR-bearing file would
|
||||
// render every row double-height.
|
||||
const source = text.replace(/\r\n?/g, "\n");
|
||||
const all = highlightLines(source, detectSyntaxLanguageFromFilePath(path));
|
||||
return source.endsWith("\n") ? all.slice(0, -1) : all;
|
||||
}, [text, path]);
|
||||
const targetLine =
|
||||
highlightLine && lines.length > 0
|
||||
? Math.min(Math.max(Math.trunc(highlightLine), 1), lines.length)
|
||||
: undefined;
|
||||
|
||||
const codeRef = useRef<HTMLPreElement>(null);
|
||||
const bandRef = useRef<HTMLDivElement>(null);
|
||||
// Highlight band geometry, measured from the code's real font metrics so it
|
||||
// lands on the same row as the gutter number (both share padding/line-height).
|
||||
const [band, setBand] = useState<{ line: number; top: number; height: number } | null>(null);
|
||||
const targetRowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const code = codeRef.current;
|
||||
if (!targetLine || !code) {
|
||||
setBand(null);
|
||||
return;
|
||||
}
|
||||
const cs = getComputedStyle(code);
|
||||
const padTop = Number.parseFloat(cs.paddingTop) || 0;
|
||||
const lineH = Number.parseFloat(cs.lineHeight) || 0;
|
||||
if (!lineH) {
|
||||
setBand(null);
|
||||
return;
|
||||
}
|
||||
setBand({ line: targetLine, top: padTop + (targetLine - 1) * lineH, height: lineH });
|
||||
// `rendered` is a dep so the band re-measures/re-scrolls once new file
|
||||
// content has laid out, not against the previous file's metrics.
|
||||
}, [rendered, targetLine]);
|
||||
|
||||
// Center the highlighted line in the scroll viewport once its band exists.
|
||||
useLayoutEffect(() => {
|
||||
// Center the highlighted line in the scroll viewport. Passive, unlike the
|
||||
// editor's: this scrolls the shared viewport, which FileViewer restores in a
|
||||
// layout effect that runs after ours and would otherwise win.
|
||||
useEffect(() => {
|
||||
if (scrollRequest === undefined) return;
|
||||
if (band && band.line === targetLine) {
|
||||
bandRef.current?.scrollIntoView({ block: "center" });
|
||||
if (targetLine) {
|
||||
targetRowRef.current?.scrollIntoView({ block: "center" });
|
||||
onScrollRequestHandled?.();
|
||||
} else if (lineCount === 0) {
|
||||
} else if (lines.length === 0) {
|
||||
onScrollRequestHandled?.();
|
||||
}
|
||||
}, [band, lineCount, onScrollRequestHandled, scrollRequest, targetLine]);
|
||||
}, [lines.length, onScrollRequestHandled, scrollRequest, targetLine]);
|
||||
|
||||
const { ruleCh } = codeGutter(lines.length);
|
||||
|
||||
// Rows are memoized so an unrelated parent re-render (chat streaming) doesn't
|
||||
// reconcile every line of the open file.
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={i + 1 === targetLine ? targetRowRef : undefined}
|
||||
className={`file-view-line flex items-stretch ${
|
||||
i + 1 === targetLine
|
||||
? "file-view-line-highlight bg-[color-mix(in_srgb,_var(--primary)_16%,_transparent)] shadow-[inset_2px_0_0_var(--primary)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* The number is generated content so a drag-selection over the
|
||||
code never picks it up. */}
|
||||
<span
|
||||
data-line={i + 1}
|
||||
className={`${CODE_GUTTER_CLASS_NAME} before:content-[attr(data-line)] shrink-0 pr-[1ch]`}
|
||||
style={{ width: `${ruleCh}ch` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code
|
||||
className={`file-view-code flex-1 min-w-0 pl-[2ch] pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME}`}
|
||||
>
|
||||
{/* An empty <code> serializes to nothing, dropping blank lines
|
||||
from a copied selection. */}
|
||||
{isBlankLine(line) ? <br /> : line}
|
||||
</code>
|
||||
</div>
|
||||
)),
|
||||
[lines, ruleCh, targetLine],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="file-view-codewrap flex items-start min-w-max relative">
|
||||
{/* No numbers for an empty file — an empty gutter is just a stray
|
||||
bordered strip. */}
|
||||
{lineCount > 0 && (
|
||||
<pre className="file-view-gutter m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-3.5 pr-2.5 text-right text-muted select-none sticky left-0 bg-background border-r border-r-border-variant shrink-0" aria-hidden="true">
|
||||
{Array.from({ length: lineCount }, (_, i) => i + 1).join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
<pre className="file-view-code m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] min-w-max" ref={codeRef}>
|
||||
<code>{rendered}</code>
|
||||
</pre>
|
||||
{band && (
|
||||
<div className={`file-view-codewrap relative py-3.5 ${CODE_TEXT_CLASS_NAME}`}>
|
||||
{lines.length > 0 && (
|
||||
<div
|
||||
ref={bandRef}
|
||||
className="file-view-line-highlight absolute left-0 right-0 pointer-events-none bg-[color-mix(in_srgb,_var(--primary)_16%,_transparent)] shadow-[inset_2px_0_0_var(--primary)]"
|
||||
style={{ top: band.top, height: band.height }}
|
||||
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
|
||||
style={{ width: `${ruleCh}ch` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{rows}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ export function FileViewer({
|
||||
};
|
||||
}, [projectId, path, source, sessionId, gitRef, nonce]);
|
||||
|
||||
// Stays a layout effect: the code views scroll to a `file:line` target in
|
||||
// passive effects, which run after this and so win over the restore.
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
const position = scrollPositionRef.current;
|
||||
|
||||
@@ -13,6 +13,17 @@ interface HastNode {
|
||||
children?: HastNode[];
|
||||
}
|
||||
|
||||
/** Tokenize `code`, best-effort: null when the language isn't registered, the
|
||||
* input is too large, or tokenizing throws — callers fall back to plain text. */
|
||||
function tokenize(code: string, lang: string | null, maxBytes: number): HastNode[] | null {
|
||||
if (!lang || !refractor.registered(lang) || code.length > maxBytes) return null;
|
||||
try {
|
||||
return refractor.highlight(code, lang).children as HastNode[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hastToReact(node: HastNode, key: number): ReactNode {
|
||||
if (node.type === "text") return node.value ?? "";
|
||||
if (node.type !== "element") return null;
|
||||
@@ -26,10 +37,49 @@ function hastToReact(node: HastNode, key: number): ReactNode {
|
||||
/** Highlight `code` in `lang`, best-effort: returns the raw string when the
|
||||
* language isn't registered, the input is too large, or tokenizing throws. */
|
||||
export function highlight(code: string, lang: string | null, maxBytes = 300_000): ReactNode {
|
||||
if (!lang || !refractor.registered(lang) || code.length > maxBytes) return code;
|
||||
try {
|
||||
return (refractor.highlight(code, lang).children as HastNode[]).map(hastToReact);
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
return tokenize(code, lang, maxBytes)?.map(hastToReact) ?? code;
|
||||
}
|
||||
|
||||
/** Highlight `code` and split it into one node per source line, so callers can
|
||||
* pair each line with a gutter number that stays put when the line wraps. */
|
||||
export function highlightLines(code: string, lang: string | null, maxBytes = 300_000): ReactNode[] {
|
||||
const tokens = tokenize(code, lang, maxBytes);
|
||||
if (!tokens) return code.split("\n");
|
||||
const lines: ReactNode[][] = [];
|
||||
let line: ReactNode[] = [];
|
||||
// Class names of the token spans enclosing the text being emitted; a token
|
||||
// that straddles a newline is reopened on the next line.
|
||||
const open: string[] = [];
|
||||
let key = 0;
|
||||
const emit = (text: string) => {
|
||||
let node: ReactNode = text;
|
||||
for (let i = open.length - 1; i >= 0; i--) {
|
||||
node = <span key={key++} className={open[i]}>{node}</span>;
|
||||
}
|
||||
line.push(node);
|
||||
};
|
||||
const walk = (node: HastNode) => {
|
||||
if (node.type === "text") {
|
||||
(node.value ?? "").split("\n").forEach((part, i) => {
|
||||
if (i > 0) {
|
||||
lines.push(line);
|
||||
line = [];
|
||||
}
|
||||
if (part) emit(part);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.type !== "element") return;
|
||||
open.push((node.properties?.className ?? []).join(" "));
|
||||
(node.children ?? []).forEach(walk);
|
||||
open.pop();
|
||||
};
|
||||
tokens.forEach(walk);
|
||||
lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A `highlightLines` entry with no content — an empty source line. */
|
||||
export function isBlankLine(line: ReactNode): boolean {
|
||||
return Array.isArray(line) ? line.length === 0 : line === "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user