Add markdown code wrap toggle

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 02:31:38 +00:00
1 parent 1edb8591e4
commit 5ab9e314cb
4 files changed
+171 -26

No files matched your search

+3
View File
@@ -449,6 +449,9 @@ describe("MarkdownBody", () => {
const html = renderMarkdown("```ts\nconst a = 1;\n```");
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-actions");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("paperclip-markdown-codeblock-copy");
expect(html).toContain('aria-label="Copy code"');
expect(html).toContain("lucide-copy");
+48 -18
View File
@@ -1,6 +1,6 @@
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, Github } from "lucide-react";
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
@@ -110,6 +110,21 @@ function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProper
};
}
function mergeCodeBlockStyle(
style: React.CSSProperties | undefined,
wrapped: boolean,
): React.CSSProperties {
if (!wrapped) return mergeScrollableBlockStyle(style);
return {
...style,
maxWidth: "100%",
overflowX: "hidden",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
wordBreak: "break-word",
};
}
function flattenText(value: ReactNode): string {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number") return String(value);
@@ -364,6 +379,7 @@ function CodeBlock({
}) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const [wrapped, setWrapped] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -402,32 +418,46 @@ function CodeBlock({
}, [children]);
const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
return (
<div className="paperclip-markdown-codeblock">
<pre
{...preProps}
ref={preRef}
style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}
style={mergeCodeBlockStyle(preProps.style as React.CSSProperties | undefined, wrapped)}
>
{children}
</pre>
<button
type="button"
onClick={handleCopy}
aria-label="Copy code"
title={label}
className="paperclip-markdown-codeblock-copy"
data-copied={copied || undefined}
data-failed={failed || undefined}
>
{copied && !failed ? (
<Check aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
)}
<span className="paperclip-markdown-codeblock-copy-label">{label}</span>
</button>
<div className="paperclip-markdown-codeblock-actions" data-active={copied || failed || undefined}>
<button
type="button"
onClick={() => setWrapped((next) => !next)}
aria-label={wrapLabel}
title={wrapLabel}
aria-pressed={wrapped}
className="paperclip-markdown-codeblock-button paperclip-markdown-codeblock-wrap"
data-wrapped={wrapped || undefined}
>
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
aria-label="Copy code"
title={label}
className="paperclip-markdown-codeblock-button paperclip-markdown-codeblock-copy"
data-copied={copied || undefined}
data-failed={failed || undefined}
>
{copied && !failed ? (
<Check aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
)}
<span className="paperclip-markdown-codeblock-copy-label">{label}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
describe("MarkdownBody code block wrapping", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
});
afterEach(() => {
flushSync(() => root.unmount());
queryClient.clear();
container.remove();
});
it("toggles fenced code blocks between horizontal scroll and wrapped lines", () => {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody>{"```text\nlong line that can wrap when requested\n```"}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
});
const pre = container.querySelector("pre");
const wrapButton = container.querySelector<HTMLButtonElement>(
".paperclip-markdown-codeblock-wrap",
);
expect(pre).not.toBeNull();
expect(wrapButton).not.toBeNull();
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
expect(pre?.style.overflowX).toBe("auto");
expect(pre?.style.whiteSpace).toBe("");
flushSync(() => {
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
expect(wrapButton?.getAttribute("aria-label")).toBe("Disable line wrap");
expect(pre?.style.overflowX).toBe("hidden");
expect(pre?.style.whiteSpace).toBe("pre-wrap");
expect(pre?.style.overflowWrap).toBe("anywhere");
flushSync(() => {
wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
expect(pre?.style.overflowX).toBe("auto");
expect(pre?.style.whiteSpace).toBe("");
});
});
+27 -8
View File
@@ -717,18 +717,28 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
background: none;
}
/* Copy-to-clipboard button on fenced code blocks */
/* Hover actions on fenced code blocks */
.paperclip-markdown-codeblock {
position: relative;
}
.paperclip-markdown-codeblock-copy {
.paperclip-markdown-codeblock-actions {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.12s ease;
}
.paperclip-markdown-codeblock-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-height: 1.55rem;
padding: 0.2rem 0.4rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
@@ -737,21 +747,30 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy,
.paperclip-markdown-codeblock-copy:focus-visible,
.paperclip-markdown-codeblock-copy[data-copied] {
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
.paperclip-markdown-codeblock-actions:focus-within,
.paperclip-markdown-codeblock-actions[data-active] {
opacity: 1;
}
.paperclip-markdown-codeblock-copy:hover {
.paperclip-markdown-codeblock-button:hover {
background-color: var(--accent);
color: var(--accent-foreground);
}
.paperclip-markdown-codeblock-wrap {
width: 1.55rem;
padding-inline: 0;
}
.paperclip-markdown-codeblock-wrap[data-wrapped] {
border-color: color-mix(in oklab, var(--primary) 38%, transparent);
color: var(--primary);
}
.paperclip-markdown-codeblock-copy[data-copied] {
color: var(--primary);
}