diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index ac0ffd5360..d40edc4e3a 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -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); diff --git a/desktop/frontend-next/src/ui/CodeBlock.test.tsx b/desktop/frontend-next/src/ui/CodeBlock.test.tsx index e954d676b4..a17c54d351 100644 --- a/desktop/frontend-next/src/ui/CodeBlock.test.tsx +++ b/desktop/frontend-next/src/ui/CodeBlock.test.tsx @@ -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( + + {"/* one\n two */"}{" x\n"} + , + ).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/); + }); +}); diff --git a/desktop/frontend-next/src/ui/CodeBlock.tsx b/desktop/frontend-next/src/ui/CodeBlock.tsx index 5385954f41..55f5913a45 100644 --- a/desktop/frontend-next/src/ui/CodeBlock.tsx +++ b/desktop/frontend-next/src/ui/CodeBlock.tsx @@ -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 (
@@ -55,11 +58,15 @@ export function CodeBlock({ lang, source, live, children }: { lang: string; sour {!live && }
-
- -
{children}
+
+
+          {rows.map((row, i) => (
+            
+              
+              {row.length ? row : "\n"}
+            
+          ))}
+        
{foldable && (