返回 DeepSeek-Reasonix
CopyButton.tsx
根目录 / desktop / frontend / src / components / CopyButton.tsx
1 import { useEffect, useRef, useState } from "react";
2 import { Check, Copy } from "lucide-react";
3 import { desktopHost } from "../lib/desktopHost";
4 import { useT } from "../lib/i18n";
5
6 function fallbackCopyText(value: string): boolean {
7 const activeElement = document.activeElement;
8 const selection = document.getSelection();
9 const ranges: Range[] = [];
10 if (selection) {
11 for (let index = 0; index < selection.rangeCount; index += 1) {
12 ranges.push(selection.getRangeAt(index));
13 }
14 }
15 const textarea = document.createElement("textarea");
16 textarea.value = value;
17 textarea.setAttribute("readonly", "");
18 textarea.style.position = "fixed";
19 textarea.style.inset = "0 auto auto 0";
20 textarea.style.width = "1px";
21 textarea.style.height = "1px";
22 textarea.style.opacity = "0";
23 document.body.appendChild(textarea);
24 textarea.select();
25 let ok = false;
26 try {
27 ok = document.execCommand("copy");
28 } finally {
29 textarea.remove();
30 if (selection) {
31 selection.removeAllRanges();
32 for (const range of ranges) selection.addRange(range);
33 }
34 if (activeElement instanceof HTMLElement) activeElement.focus({ preventScroll: true });
35 }
36 return ok;
37 }
38
39 async function writeClipboardText(value: string): Promise<void> {
40 try {
41 await navigator.clipboard.writeText(value);
42 return;
43 } catch {
44 /* try the desktop runtime below */
45 }
46 try {
47 if (await desktopHost().native.clipboardWriteText(value)) return;
48 } catch {
49 /* host clipboard unavailable in browser dev */
50 }
51 if (fallbackCopyText(value)) return;
52 throw new Error("clipboard unavailable");
53 }
54
55 // CopyButton copies text to the clipboard on click and briefly flips to a check.
56 // Acknowledgement follows successful content resolution and clipboard write.
57 export function CopyButton({
58 text,
59 getText,
60 className,
61 label,
62 showInlineLabel = true,
63 }: {
64 text?: string;
65 getText?: () => string | Promise<string>;
66 className?: string;
67 label?: string;
68 showInlineLabel?: boolean;
69 }) {
70 const t = useT();
71 const [copied, setCopied] = useState(false);
72 const [pending, setPending] = useState(false);
73 const [failed, setFailed] = useState(false);
74 const epochRef = useRef(0);
75 const timerRef = useRef<number | null>(null);
76 const actionLabel = label ?? t("msg.copy");
77 const stateLabel = pending ? t("chat.loading") : failed ? t("richLink.copyFailed") : copied ? t("msg.copied") : actionLabel;
78
79 useEffect(() => {
80 return () => {
81 epochRef.current++;
82 if (timerRef.current != null) window.clearTimeout(timerRef.current);
83 };
84 }, []);
85
86 const copy = async () => {
87 const epoch = epochRef.current;
88 setPending(true); setFailed(false);
89 try {
90 const value = getText ? await getText() : text ?? "";
91 if (epoch !== epochRef.current) return;
92 await writeClipboardText(value);
93 if (epoch !== epochRef.current) return;
94 setCopied(true);
95 if (timerRef.current != null) window.clearTimeout(timerRef.current);
96 timerRef.current = window.setTimeout(() => {
97 setCopied(false);
98 timerRef.current = null;
99 }, 1200);
100 } catch {
101 if (epoch === epochRef.current) setFailed(true);
102 } finally {
103 if (epoch === epochRef.current) setPending(false);
104 }
105 };
106 return (
107 <button
108 className={[
109 "copybtn",
110 copied ? "copybtn--copied" : "",
111 className ?? "",
112 ].filter(Boolean).join(" ")}
113 onClick={copy}
114 disabled={pending}
115 aria-busy={pending}
116 aria-label={stateLabel}
117 title={stateLabel}
118 type="button"
119 >
120 {copied ? <Check size={13} /> : <Copy size={13} />}
121 {showInlineLabel && (
122 <span className="copybtn__label-inline">{stateLabel}</span>
123 )}
124 </button>
125 );
126 }
127
127 lines Plain Text