mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Fix markdown code block wrap action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4ec7f4569b
commit
be11fe3b00
3 files changed
+160
-31
No files matched your search
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
flushSync(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderMarkdown(children: string) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function click(element: Element | null) {
|
||||
if (!element) throw new Error("Expected element to exist");
|
||||
flushSync(() => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("MarkdownBody code block interactions", () => {
|
||||
it("toggles line wrapping for indented preformatted markdown blocks", () => {
|
||||
const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
|
||||
const pre = node.querySelector("pre");
|
||||
const wrapButton = node.querySelector<HTMLButtonElement>(".paperclip-markdown-codeblock-wrap");
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
|
||||
click(wrapButton);
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("pre-wrap");
|
||||
expect(pre?.style.overflowWrap).toBe("anywhere");
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
|
||||
|
||||
click(wrapButton);
|
||||
|
||||
expect(pre?.style.whiteSpace).toBe("");
|
||||
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -364,6 +364,7 @@ function CodeBlock({
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
@@ -401,33 +402,57 @@ function CodeBlock({
|
||||
}, 1500);
|
||||
}, [children]);
|
||||
|
||||
const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
|
||||
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
|
||||
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
|
||||
|
||||
return (
|
||||
<div className="paperclip-markdown-codeblock">
|
||||
<div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
|
||||
<pre
|
||||
{...preProps}
|
||||
ref={preRef}
|
||||
style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}
|
||||
style={{
|
||||
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
|
||||
...(wrapLines
|
||||
? {
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "break-word",
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
aria-label={wrapLabel}
|
||||
title={wrapLabel}
|
||||
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
|
||||
aria-pressed={wrapLines}
|
||||
data-active={wrapLines || undefined}
|
||||
>
|
||||
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
title={copyLabel}
|
||||
className="paperclip-markdown-codeblock-action 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-action-label">{copyLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-11
@@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Copy-to-clipboard button on fenced code blocks */
|
||||
/* Actions for fenced and indented preformatted markdown 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-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -737,30 +745,31 @@ 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;
|
||||
}
|
||||
|
||||
.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-action[data-copied],
|
||||
.paperclip-markdown-codeblock-action[data-active] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy:hover {
|
||||
.paperclip-markdown-codeblock-action:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy[data-copied] {
|
||||
.paperclip-markdown-codeblock-action[data-active],
|
||||
.paperclip-markdown-codeblock-action[data-copied] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy[data-failed] {
|
||||
.paperclip-markdown-codeblock-action[data-failed] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock-copy-label {
|
||||
.paperclip-markdown-codeblock-action-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user