mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Add copy button for rendered code blocks (#619)
Signed-off-by: Mat Balez <60949391+matbalez@users.noreply.github.com>
This commit is contained in:
@@ -40,7 +40,7 @@ const overrides = new Map([
|
||||
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
|
||||
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
|
||||
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
|
||||
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572)
|
||||
["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
||||
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useRichTextEditor,
|
||||
} from "@/features/messages/lib/useRichTextEditor";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { getSproutCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
@@ -137,6 +138,15 @@ export function MessageComposer({
|
||||
emojiAutocomplete.isEmojiAutocompleteOpen;
|
||||
|
||||
const submitMessageRef = React.useRef<() => void>(() => {});
|
||||
const composerScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollComposerToBottom = React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const scrollElement = composerScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const computedPlaceholder = editTarget
|
||||
? "Edit your message"
|
||||
@@ -505,6 +515,33 @@ export function MessageComposer({
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Sprout code-block paste ---
|
||||
// The code block copy button writes a small Sprout marker alongside
|
||||
// plain text. Use it to paste back as a literal code block so Markdown
|
||||
// parsing cannot reshape indentation, fence markers, or headings.
|
||||
const codeBlockText = getSproutCodeBlockClipboardText(
|
||||
event.clipboardData,
|
||||
);
|
||||
if (codeBlockText !== null) {
|
||||
event.preventDefault();
|
||||
richText.editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "codeBlock",
|
||||
content:
|
||||
codeBlockText.length > 0
|
||||
? [{ type: "text", text: codeBlockText }]
|
||||
: [],
|
||||
},
|
||||
{ type: "paragraph" },
|
||||
])
|
||||
.run();
|
||||
scrollComposerToBottom();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Mention / channel-link normalization ---
|
||||
// When copying from the chat area the browser puts styled HTML
|
||||
// on the clipboard. TipTap's DOMParser doesn't understand our
|
||||
@@ -531,11 +568,16 @@ export function MessageComposer({
|
||||
return true;
|
||||
}
|
||||
|
||||
const plainText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (plainText.includes("\n")) {
|
||||
scrollComposerToBottom();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [richText.editor]);
|
||||
}, [richText.editor, scrollComposerToBottom]);
|
||||
|
||||
// ── Send button state ───────────────────────────────────────────────
|
||||
const sendDisabled = React.useMemo(
|
||||
@@ -680,6 +722,8 @@ export function MessageComposer({
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
|
||||
<div
|
||||
className="rich-text-composer max-h-32 overflow-y-auto"
|
||||
data-testid="message-input-scroll"
|
||||
ref={composerScrollRef}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
>
|
||||
<EditorContent editor={richText.editor} />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const SPROUT_CODE_BLOCK_ATTRIBUTE = "data-sprout-code-block";
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function createSproutCodeBlockHtml(code: string) {
|
||||
// Keep the code as one text node; the paste reader recovers it via textContent.
|
||||
return `<pre ${SPROUT_CODE_BLOCK_ATTRIBUTE}="true"><code>${escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
|
||||
export async function copyCodeBlockToClipboard(code: string) {
|
||||
const clipboard = navigator.clipboard;
|
||||
if (!clipboard) {
|
||||
throw new Error("Clipboard API is unavailable");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof ClipboardItem !== "undefined" &&
|
||||
typeof clipboard.write === "function"
|
||||
) {
|
||||
try {
|
||||
await clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([createSproutCodeBlockHtml(code)], {
|
||||
type: "text/html",
|
||||
}),
|
||||
"text/plain": new Blob([code], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn("Failed to write rich code block clipboard data", error);
|
||||
}
|
||||
}
|
||||
|
||||
await clipboard.writeText(code);
|
||||
}
|
||||
|
||||
export function getSproutCodeBlockClipboardText(
|
||||
clipboardData: DataTransfer | null | undefined,
|
||||
) {
|
||||
const html = clipboardData?.getData("text/html");
|
||||
if (!html?.includes(SPROUT_CODE_BLOCK_ATTRIBUTE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const code = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}] code`);
|
||||
const fallback = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}]`);
|
||||
|
||||
return code?.textContent ?? fallback?.textContent ?? null;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Copy } from "lucide-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
@@ -11,12 +11,15 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
|
||||
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
|
||||
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
|
||||
import remarkMentions from "@/shared/lib/remarkMentions";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
import {
|
||||
classifyChildren,
|
||||
@@ -103,6 +106,75 @@ function ImageContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function getReactNodeText(node: React.ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getReactNodeText).join("");
|
||||
}
|
||||
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
|
||||
return getReactNodeText(node.props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getCodeBlockText(children: React.ReactNode) {
|
||||
return getReactNodeText(children).replace(/\n$/, "");
|
||||
}
|
||||
|
||||
function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
|
||||
const [isCopying, setIsCopying] = React.useState(false);
|
||||
const code = React.useMemo(() => getCodeBlockText(children), [children]);
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsCopying(true);
|
||||
|
||||
try {
|
||||
await copyCodeBlockToClipboard(code);
|
||||
toast.success("Copied code to clipboard");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy code block", error);
|
||||
toast.error("Failed to copy code");
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
},
|
||||
[code],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-sm">
|
||||
{children}
|
||||
</pre>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy code block"
|
||||
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-sm ring-1 ring-border/60 backdrop-blur transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
|
||||
disabled={isCopying}
|
||||
onClick={handleCopy}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Copy code block</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createMarkdownComponents(
|
||||
variant: MarkdownVariant,
|
||||
channels: Channel[],
|
||||
@@ -141,18 +213,12 @@ function createMarkdownComponents(
|
||||
</blockquote>
|
||||
),
|
||||
br: () => <br />,
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"code"> & { inline?: boolean }) => {
|
||||
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
const isBlock =
|
||||
typeof className === "string" && className.includes("language-")
|
||||
? true
|
||||
: code.includes("\n");
|
||||
const isFencedCodeBlock =
|
||||
typeof className === "string" && className.includes("language-");
|
||||
|
||||
if (isBlock) {
|
||||
if (isFencedCodeBlock || code.includes("\n")) {
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
@@ -297,11 +363,7 @@ function createMarkdownComponents(
|
||||
|
||||
return <p className={paragraphClassName}>{children}</p>;
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 shadow-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold">{children}</strong>
|
||||
),
|
||||
|
||||
@@ -49,6 +49,91 @@ test("send multiple messages in sequence", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("copy a rendered code block and paste it back as code", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: "http://127.0.0.1:4173",
|
||||
});
|
||||
|
||||
const code = "# not a heading\nconst answer = 42;\n indented();";
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await page.evaluate(
|
||||
(text) => navigator.clipboard.writeText(text),
|
||||
`\`\`\`ts\n${code}\n\`\`\``,
|
||||
);
|
||||
await input.click();
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const copiedCodeBlock = page.locator("pre", { hasText: code });
|
||||
await expect(copiedCodeBlock).toHaveCount(1);
|
||||
|
||||
const copyButton = page.getByLabel("Copy code block");
|
||||
await expect(copyButton).toHaveCSS("opacity", "0");
|
||||
await copiedCodeBlock.hover();
|
||||
await expect(copyButton).toHaveCSS("opacity", "1");
|
||||
await copyButton.click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(code);
|
||||
|
||||
await input.click();
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(copiedCodeBlock).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("pasting a long copied code block scrolls composer to cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: "http://127.0.0.1:4173",
|
||||
});
|
||||
|
||||
const longCode = Array.from(
|
||||
{ length: 48 },
|
||||
(_, index) => `const line${index} = ${index};`,
|
||||
).join("\n");
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await page.evaluate(
|
||||
(text) => navigator.clipboard.writeText(text),
|
||||
`\`\`\`ts\n${longCode}\n\`\`\``,
|
||||
);
|
||||
await input.click();
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const copiedCodeBlock = page.locator("pre", { hasText: longCode });
|
||||
await expect(copiedCodeBlock).toHaveCount(1);
|
||||
await copiedCodeBlock.hover();
|
||||
await page.getByLabel("Copy code block").click();
|
||||
|
||||
await input.fill("typed before paste");
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
|
||||
const scrollContainer = page.getByTestId("message-input-scroll");
|
||||
await expect
|
||||
.poll(() =>
|
||||
scrollContainer.evaluate(
|
||||
(element) =>
|
||||
element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("message input clears after send", async ({ page }) => {
|
||||
const message = `Clear after send ${Date.now()}`;
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
Reference in New Issue
Block a user