mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-26 09:55:59 +00:00
fix(frontend): guard message copy clipboard access (#3211)
* fix(frontend): guard message copy clipboard access * fix(frontend): reuse clipboard guard across copy actions
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -146,20 +147,20 @@ export const CodeBlockCopyButton = ({
|
|||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const { code } = useContext(CodeBlockContext);
|
const { code } = useContext(CodeBlockContext);
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
const copyToClipboard = () => {
|
||||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
void (async () => {
|
||||||
onError?.(new Error("Clipboard API not available"));
|
const didCopy = await writeTextToClipboard(code);
|
||||||
return;
|
if (!didCopy) {
|
||||||
}
|
onError?.(new Error("Clipboard API not available"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
onCopy?.();
|
onCopy?.();
|
||||||
setTimeout(() => setIsCopied(false), timeout);
|
setTimeout(() => setIsCopied(false), timeout);
|
||||||
} catch (error) {
|
})().catch((error) => {
|
||||||
onError?.(error as Error);
|
onError?.(error as Error);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||||
} from "@/core/artifacts/preview";
|
} from "@/core/artifacts/preview";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { findToolCallResult } from "@/core/messages/utils";
|
import { findToolCallResult } from "@/core/messages/utils";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
@@ -237,14 +238,20 @@ export function ArtifactFileDetail({
|
|||||||
icon={CopyIcon}
|
icon={CopyIcon}
|
||||||
label={t.clipboard.copyToClipboard}
|
label={t.clipboard.copyToClipboard}
|
||||||
disabled={!content}
|
disabled={!content}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
try {
|
void (async () => {
|
||||||
await navigator.clipboard.writeText(visibleContent ?? "");
|
const didCopy = await writeTextToClipboard(
|
||||||
|
visibleContent ?? "",
|
||||||
|
);
|
||||||
|
if (!didCopy) {
|
||||||
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(t.clipboard.copiedToClipboard);
|
toast.success(t.clipboard.copiedToClipboard);
|
||||||
} catch (error) {
|
})().catch(() => {
|
||||||
toast.error("Failed to copy to clipboard");
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
console.error(error);
|
});
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
tooltip={t.clipboard.copyToClipboard}
|
tooltip={t.clipboard.copyToClipboard}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import { useCallback, useState, type ComponentProps } from "react";
|
import { useCallback, useState, type ComponentProps } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
@@ -15,10 +17,19 @@ export function CopyButton({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
void navigator.clipboard.writeText(clipboardData);
|
void (async () => {
|
||||||
setCopied(true);
|
const didCopy = await writeTextToClipboard(clipboardData);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
if (!didCopy) {
|
||||||
}, [clipboardData]);
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
})().catch(() => {
|
||||||
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
});
|
||||||
|
}, [clipboardData, t.clipboard.failedToCopyToClipboard]);
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
exportThreadAsJSON,
|
exportThreadAsJSON,
|
||||||
@@ -126,7 +127,12 @@ export function RecentChatList() {
|
|||||||
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
||||||
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
|
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
const didCopy = await writeTextToClipboard(shareUrl);
|
||||||
|
if (!didCopy) {
|
||||||
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(t.clipboard.linkCopied);
|
toast.success(t.clipboard.linkCopied);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const clipboard = globalThis.navigator?.clipboard;
|
||||||
|
if (clipboard?.writeText) {
|
||||||
|
await clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = globalThis.document;
|
||||||
|
if (!document?.body?.appendChild || !document.execCommand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.top = "-9999px";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return document.execCommand("copy");
|
||||||
|
} finally {
|
||||||
|
textarea.remove();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
|
|
||||||
|
const originalNavigator = globalThis.navigator;
|
||||||
|
const hadOriginalNavigator = "navigator" in globalThis;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const hadOriginalDocument = "document" in globalThis;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (!hadOriginalNavigator) {
|
||||||
|
Reflect.deleteProperty(globalThis, "navigator");
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalNavigator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hadOriginalDocument) {
|
||||||
|
Reflect.deleteProperty(globalThis, "document");
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalDocument,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes text with the Clipboard API when available", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clipboard: {
|
||||||
|
writeText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when Clipboard API is unavailable", async () => {
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to execCommand when Clipboard API is unavailable", async () => {
|
||||||
|
const textarea = {
|
||||||
|
remove: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
style: {},
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
const appendChild = vi.fn();
|
||||||
|
const execCommand = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
body: {
|
||||||
|
appendChild,
|
||||||
|
},
|
||||||
|
createElement: vi.fn().mockReturnValue(textarea),
|
||||||
|
execCommand,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
|
||||||
|
expect(textarea.value).toBe("hello");
|
||||||
|
expect(appendChild).toHaveBeenCalledWith(textarea);
|
||||||
|
expect(textarea.select).toHaveBeenCalled();
|
||||||
|
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||||
|
expect(textarea.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when execCommand fallback fails", async () => {
|
||||||
|
const textarea = {
|
||||||
|
remove: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
style: {},
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
body: {
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
},
|
||||||
|
createElement: vi.fn().mockReturnValue(textarea),
|
||||||
|
execCommand: vi.fn().mockReturnValue(false),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||||
|
expect(textarea.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when navigator is unavailable", async () => {
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when Clipboard API rejects", async () => {
|
||||||
|
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clipboard: {
|
||||||
|
writeText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user