mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add markdown code wrap toggle
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1edb8591e4
commit
5ab9e314cb
4 files changed
+171
-26
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user