fix(studio): a code block wraps long lines instead of scrolling sideways; each source line is its own row carrying its number, so the number stays with the first row of a wrapped line

This commit is contained in:
yhh committed 2026-11-06 18:33:23 -08:00
1 parent 2bfb7de07a
commit ce51afd2dc
4 files changed
+84 -13

No files matched your search

+8 -6
View File
@@ -3509,14 +3509,16 @@
.code-act:hover, .code-act:focus-visible { color: var(--text); background: var(--overlay) }
.code-act .studio-icon { width: 14px; height: 14px }
.code-act[data-state="done"] { color: var(--ok) }
.code-body { display: flex; align-items: flex-start }
.code-gutter {
flex: none; padding: 11px 10px 12px 12px; text-align: end;
.code-body { display: block }
.code-body > pre.term { margin: 0; border-radius: 0; background: none; tab-size: 4; overflow-x: hidden; padding: 11px 0 12px }
.code-ln { display: flex; align-items: flex-start }
.code-no {
flex: none; box-sizing: content-box; width: var(--ln-w); padding: 0 10px 0 12px; text-align: end; font-style: normal;
font: var(--w-reg) var(--fs-ui)/var(--fs-title) var(--mono); color: var(--ghost); user-select: none;
border-inline-end: 1px solid var(--hair);
border-inline-end: 1px solid var(--hair); align-self: stretch;
}
.code-gutter > span { display: block }
.code-body > pre.term { flex: 1; min-width: 0; margin: 0; border-radius: 0; background: none; tab-size: 4 }
.code-tx { flex: 1; min-width: 0; padding-inline: 14px 12px; white-space: pre-wrap; overflow-wrap: anywhere }
.md .code-body pre.term .code-tx code { white-space: inherit; overflow-wrap: inherit }
.code-wrap[data-folded] .code-body {
max-height: calc(19px * 12 + 11px); overflow: hidden;
mask-image: linear-gradient(to bottom, #000 72%, transparent);
@@ -4,6 +4,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { CodeBlock, extensionFor } from "./CodeBlock";
import css from "../styles/app.css?raw";
afterEach(cleanup);
@@ -18,7 +19,7 @@ describe("a code block", () => {
const box = draw(lines(3));
expect(box.querySelector(".code-lang")?.textContent).toBe("go");
expect(box.querySelector(".code-lines")?.textContent).toContain("3");
expect([...box.querySelectorAll(".code-gutter > span")].map((li) => li.textContent)).toEqual(["1", "2", "3"]);
expect([...box.querySelectorAll(".code-no")].map((li) => li.textContent)).toEqual(["1", "2", "3"]);
});
it("folds a long settled block and opens it on request", async () => {
@@ -40,3 +41,39 @@ describe("a code block", () => {
expect(extensionFor("brainfuck")).toBe("txt");
});
});
// A long line wraps inside the block instead of scrolling it sideways, so its
// number has to stay with the first row of the line it names.
describe("a code block's rows", () => {
const rows = (box: HTMLElement) => [...box.querySelectorAll(".code-ln")];
it("is one row per source line, blank lines included, with no row for the closing newline", () => {
const box = draw("a\n\nb\n");
expect(rows(box).map((r) => r.querySelector(".code-no")?.textContent)).toEqual(["1", "2", "3"]);
expect(rows(box).map((r) => r.querySelector(".code-tx")?.textContent?.replace(/\n$/, ""))).toEqual(["a", "", "b"]);
});
it("splits a highlighted element that spans a newline into one piece per row", () => {
const source = "/* one\n two */ x\n";
const box = render(
<CodeBlock lang="go" source={source}>
<code className="hljs"><span className="hljs-comment">{"/* one\n two */"}</span>{" x\n"}</code>
</CodeBlock>,
).container;
const [first, second] = rows(box);
expect(first.querySelector(".hljs-comment")?.textContent).toBe("/* one");
expect(second.querySelector(".hljs-comment")?.textContent).toBe(" two */");
expect(second.querySelector(".code-tx")?.textContent).toContain(" x");
});
it("keeps the numbers out of what a reader selects and out of the accessibility tree", () => {
const box = draw(lines(2));
for (const no of box.querySelectorAll(".code-no")) expect(no.getAttribute("aria-hidden")).toBe("true");
});
it("wraps long lines in the stylesheet rather than scrolling the block", () => {
const rule = css.match(/\.code-tx\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rule).toMatch(/white-space:\s*pre-wrap/);
expect(rule).toMatch(/overflow-wrap:\s*anywhere/);
});
});
+13 -6
View File
@@ -1,7 +1,8 @@
import { useState, type ReactNode } from "react";
import { useState, type CSSProperties, type ReactNode } from "react";
import { t } from "../i18n";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
import { splitLines } from "./codelines";
// Past this many lines a settled block opens folded to FOLDED_LINES, so one long
// file in a reply does not push the answer around it off the screen.
@@ -41,6 +42,8 @@ export function CodeBlock({ lang, source, live, children }: { lang: string; sour
const foldable = !live && lines > FOLD_OVER;
const [open, setOpen] = useState(false);
const folded = foldable && !open;
const rows = splitLines(children);
while (rows.length > lines && rows[rows.length - 1].length === 0) rows.pop();
return (
<div className="code-wrap" data-lang={lang || undefined} data-folded={folded ? "" : undefined}>
<div className="code-hd">
@@ -55,11 +58,15 @@ export function CodeBlock({ lang, source, live, children }: { lang: string; sour
{!live && <CopyButton text={source} iconOnly className="code-act" label={t("chat.copy_this_code")} />}
</span>
</div>
<div className="code-body">
<div className="code-gutter" aria-hidden="true">
{Array.from({ length: lines }, (_, i) => <span key={i}>{i + 1}</span>)}
</div>
<pre className="term">{children}</pre>
<div className="code-body" style={{ "--ln-w": `${String(lines).length + 1}ch` } as CSSProperties}>
<pre className="term">
{rows.map((row, i) => (
<span className="code-ln" key={i}>
<i className="code-no" aria-hidden="true">{i + 1}</i>
<span className="code-tx">{row.length ? row : "\n"}</span>
</span>
))}
</pre>
</div>
{foldable && (
<button type="button" className="code-more" data-action="code.fold" aria-expanded={open} onClick={() => setOpen(!open)}>
+25
View File
@@ -0,0 +1,25 @@
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
type Row = ReactNode[];
function join(head: Row[], tail: Row[]): Row[] {
return [...head.slice(0, -1), [...head[head.length - 1], ...tail[0]], ...tail.slice(1)];
}
function rowsOf(node: ReactNode): Row[] {
if (node == null || typeof node === "boolean") return [[]];
if (typeof node === "number") return [[String(node)]];
if (typeof node === "string") return node.split("\n").map((piece) => (piece === "" ? [] : [piece]));
if (Array.isArray(node)) return node.reduce<Row[]>((all, child) => join(all, rowsOf(child)), [[]]);
if (!isValidElement(node)) return [[]];
const element = node as ReactElement<{ children?: ReactNode }>;
const inner = rowsOf(element.props.children);
if (inner.length === 1) return [[element]];
return inner.map((row, i) => (row.length === 0 ? [] : [cloneElement(element, { key: `${element.key ?? "n"}-${i}` }, ...row)]));
}
/** The rows of a highlighted tree, one per source line. An element that spans a
* newline is repeated in every row it touches, so each row stands on its own. */
export function splitLines(tree: ReactNode): ReactNode[][] {
return rowsOf(tree);
}