Skip to content

Commit 5732438

Browse files
committed
feat(copy): 添加通用复制按钮组件,优化复制逻辑
1 parent 95a6619 commit 5732438

7 files changed

Lines changed: 179 additions & 167 deletions

File tree

src/components/chat/ChatView.tsx

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useMemo, useCallback, useRef, useState, useEffect } from 'react'
22
import { CloseCircleFilled, SyncOutlined } from '@ant-design/icons';
33
import { Typography, Button, Dropdown, Input, App, Avatar, Alert, Popconfirm, Popover, theme, Tag, Image, Tooltip, Modal, Spin } from 'antd';
44
import type { InputRef } from 'antd';
5-
import { Pencil, Share2, FileImage, FileCode, FileText, FileType, Bot, Brain, Lightbulb, Code, Languages, Copy, RotateCcw, User, Trash2, ChevronLeft, ChevronRight, ChevronDown, Scissors, Paperclip, AlertCircle, X, ArrowDown, ArrowUp, ArrowLeftRight, Zap, Sparkles, TextCursorInput, GitBranch, ChartNoAxesColumn, MessageSquare, ArrowUpRight, ArrowDownRight, Coins, Clock, Timer } from 'lucide-react';
5+
import { Pencil, Share2, FileImage, FileCode, FileText, FileType, Bot, Brain, Lightbulb, Code, Languages, Copy, Check, RotateCcw, User, Trash2, ChevronLeft, ChevronRight, ChevronDown, Scissors, Paperclip, AlertCircle, X, ArrowDown, ArrowUp, ArrowLeftRight, Zap, Sparkles, TextCursorInput, GitBranch, ChartNoAxesColumn, MessageSquare, ArrowUpRight, ArrowDownRight, Coins, Clock, Timer } from 'lucide-react';
66
import { ModelIcon } from '@lobehub/icons';
77
import { getConvIcon } from '@/lib/convIcon';
88
import Bubble from '@ant-design/x/es/bubble';
@@ -28,6 +28,7 @@ import { McpContainerNode } from './McpContainerNode';
2828
import { getDistanceToHistoryTop, shouldShowScrollToBottom } from './chatScroll';
2929
import { formatTokenCount, formatSpeed, formatDuration } from '../gateway/tokenFormat';
3030
import { getStreamingLoadingState } from './chatStreaming';
31+
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
3132
import { buildAssistantDisplayContent, shouldHideAssistantBubble } from './toolCallDisplay';
3233
import { ChatScrollIndicator } from './ChatScrollIndicator';
3334
import { ChatMinimap, MinimapScrollProvider } from './ChatMinimap';
@@ -504,7 +505,7 @@ function ChatD2BlockNode({
504505
const { t } = useTranslation();
505506
const containerRef = useRef<HTMLDivElement | null>(null);
506507
const [showSource, setShowSource] = useState(false);
507-
const [copied, setCopied] = useState(false);
508+
const { copy: copyD2, isCopied: d2Copied } = useCopyToClipboard({ timeout: 1000 });
508509
const [svgMarkup, setSvgMarkup] = useState('');
509510
const [error, setError] = useState<string | null>(null);
510511
const [canRenderPreview, setCanRenderPreview] = useState(false);
@@ -652,15 +653,6 @@ function ChatD2BlockNode({
652653
};
653654
}, [canRenderPreview, isDark, node.code, showSource, token.colorBgContainer, token.colorBgElevated, token.colorBorder, token.colorBorderSecondary, token.colorText, token.colorTextQuaternary, token.colorTextSecondary, token.colorTextTertiary]);
654655

655-
const handleCopy = useCallback(async () => {
656-
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
657-
return;
658-
}
659-
660-
await navigator.clipboard.writeText(node.code);
661-
setCopied(true);
662-
window.setTimeout(() => setCopied(false), 1000);
663-
}, [node.code]);
664656

665657
const handleExport = useCallback(() => {
666658
if (!svgMarkup) return;
@@ -714,8 +706,8 @@ function ChatD2BlockNode({
714706
{t('common.source')}
715707
</button>
716708
</div>
717-
<button type="button" className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]" aria-label={copied ? 'Copied' : 'Copy'} onClick={() => void handleCopy()}>
718-
<Copy size={14} />
709+
<button type="button" className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]" aria-label={d2Copied ? 'Copied' : 'Copy'} onClick={() => void copyD2(node.code)}>
710+
{d2Copied ? <Check size={14} /> : <Copy size={14} />}
719711
</button>
720712
{svgMarkup ? (
721713
<button type="button" className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]" aria-label="Export" onClick={handleExport}>
@@ -1366,6 +1358,7 @@ function AssistantFooter({
13661358
const deleteMessageGroup = useConversationStore((s) => s.deleteMessageGroup);
13671359
const switchMessageVersion = useConversationStore((s) => s.switchMessageVersion);
13681360
const branchConversation = useConversationStore((s) => s.branchConversation);
1361+
const { copy: copyAssistant, isCopied: assistantCopied } = useCopyToClipboard();
13691362
// Branch modal state
13701363
const [branchModalOpen, setBranchModalOpen] = useState(false);
13711364
const [branchAsChild, setBranchAsChild] = useState(false);
@@ -1469,12 +1462,12 @@ function AssistantFooter({
14691462
items={[
14701463
{
14711464
key: 'copy',
1472-
icon: <Copy size={14} />,
1465+
icon: assistantCopied ? <Check size={14} style={{ color: token.colorSuccess }} /> : <Copy size={14} />,
14731466
label: t('chat.copy'),
14741467
onItemClick: () => {
1475-
navigator.clipboard
1476-
.writeText(assistantCopyText)
1477-
.then(() => messageApi.success(t('chat.copied')));
1468+
void copyAssistant(assistantCopyText).then(ok => {
1469+
if (ok) messageApi.success(t('chat.copied'));
1470+
});
14781471
},
14791472
},
14801473
{
@@ -1789,6 +1782,7 @@ export function ChatView() {
17891782
const profile = useUserProfileStore((s) => s.profile);
17901783
const resolvedAvatarSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
17911784
const isDarkMode = useResolvedDarkMode(settings.theme_mode);
1785+
const { copy: copyMessage, isCopiedFor: isUserMsgCopied } = useCopyToClipboard();
17921786
const { darkTheme: codeBlockDarkTheme, themes: codeBlockThemes } = useMemo(
17931787
() => getChatCodeThemes(settings.code_theme),
17941788
[settings.code_theme],
@@ -2476,12 +2470,12 @@ export function ChatView() {
24762470
items={[
24772471
{
24782472
key: 'copy',
2479-
icon: <Copy size={14} />,
2473+
icon: (() => { const ct = stripAqbotTags(String(bubbleData.content ?? '')); return isUserMsgCopied(ct) ? <Check size={14} style={{ color: token.colorSuccess }} /> : <Copy size={14} />; })(),
24802474
label: t('chat.copy'),
24812475
onItemClick: () => {
2482-
navigator.clipboard
2483-
.writeText(stripAqbotTags(String(bubbleData.content ?? '')))
2484-
.then(() => messageApi.success(t('chat.copied')));
2476+
void copyMessage(stripAqbotTags(String(bubbleData.content ?? ''))).then(ok => {
2477+
if (ok) messageApi.success(t('chat.copied'));
2478+
});
24852479
},
24862480
},
24872481
{
@@ -2594,10 +2588,6 @@ export function ChatView() {
25942588
conversationId={activeConversationId}
25952589
onSwitchVersion={(pid, mid) => switchMessageVersion(activeConversationId, pid, mid)}
25962590
onDeleteVersion={(mid) => deleteMessage(mid)}
2597-
onCopyContent={(content) => {
2598-
const text = stripAqbotTags(content);
2599-
navigator.clipboard.writeText(text).catch(() => {});
2600-
}}
26012591
streamingMessageId={streamingMessageId}
26022592
multiModelDoneMessageIds={multiModelDoneMessageIds}
26032593
getModelDisplayInfo={getModelDisplayInfo}

src/components/chat/MultiModelDisplay.tsx

Lines changed: 11 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2-
import { Alert, Popconfirm, Tag, Tooltip, Typography, theme } from 'antd';
3-
import { Check, Columns2, Copy, LayoutList, Rows3, Trash2 } from 'lucide-react';
1+
import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2+
import { Alert, Button, Popconfirm, Tag, Tooltip, Typography, theme } from 'antd';
3+
import { Check, Columns2, LayoutList, Rows3, Trash2 } from 'lucide-react';
44
import { ModelIcon } from '@lobehub/icons';
55
import { useTranslation } from 'react-i18next';
66
import { OverlayScrollbars } from 'overlayscrollbars';
77
import type { Message } from '@/types';
8+
import { CopyButton } from '@/components/common/CopyButton';
9+
import { stripAqbotTags } from '@/lib/chatMarkdown';
810

911
export type MultiModelDisplayMode = 'tabs' | 'side-by-side' | 'stacked';
1012

@@ -37,7 +39,6 @@ export interface MultiModelDisplayProps {
3739
conversationId: string;
3840
onSwitchVersion: (parentMessageId: string, messageId: string) => void;
3941
onDeleteVersion?: (messageId: string) => void;
40-
onCopyContent?: (content: string) => void;
4142
renderContent: (msg: Message, isVersionStreaming: boolean) => React.ReactNode;
4243
getModelDisplayInfo: (
4344
modelId?: string | null,
@@ -57,7 +58,6 @@ export const MultiModelDisplay = React.memo(function MultiModelDisplay({
5758
mode,
5859
onSwitchVersion,
5960
onDeleteVersion,
60-
onCopyContent,
6161
renderContent,
6262
getModelDisplayInfo,
6363
streamingMessageId,
@@ -76,7 +76,6 @@ export const MultiModelDisplay = React.memo(function MultiModelDisplay({
7676
mode={mode}
7777
onSwitchVersion={onSwitchVersion}
7878
onDeleteVersion={onDeleteVersion}
79-
onCopyContent={onCopyContent}
8079
renderContent={renderContent}
8180
getModelDisplayInfo={getModelDisplayInfo}
8281
streamingMessageId={streamingMessageId}
@@ -98,7 +97,6 @@ function MultiModelDisplayInner({
9897
mode,
9998
onSwitchVersion,
10099
onDeleteVersion,
101-
onCopyContent,
102100
renderContent,
103101
getModelDisplayInfo,
104102
streamingMessageId,
@@ -118,8 +116,6 @@ function MultiModelDisplayInner({
118116
}, [versions]);
119117

120118
const parentMessageId = versions[0]?.parent_message_id;
121-
// Track copy feedback: show checkmark for 1.5s after copy
122-
const [copiedId, setCopiedId] = useState<string | null>(null);
123119

124120
if (latestByModel.length <= 1) {
125121
const msg = latestByModel[0];
@@ -268,20 +264,11 @@ function MultiModelDisplayInner({
268264
</div>
269265
{/* Card action buttons */}
270266
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
271-
{/* Copy button with feedback */}
272-
{onCopyContent && (
273-
<CardActionButton
274-
token={token}
275-
onClick={() => {
276-
onCopyContent(vMsg.content);
277-
setCopiedId(vMsg.id);
278-
setTimeout(() => setCopiedId((prev) => prev === vMsg.id ? null : prev), 3000);
279-
}}
280-
forceColor={copiedId === vMsg.id ? token.colorSuccess : undefined}
281-
>
282-
{copiedId === vMsg.id ? <Check size={13} /> : <Copy size={13} />}
283-
</CardActionButton>
284-
)}
267+
<CopyButton
268+
text={() => stripAqbotTags(vMsg.content ?? '')}
269+
size={13}
270+
timeout={3000}
271+
/>
285272
{/* Delete button with confirmation */}
286273
{onDeleteVersion && latestByModel.length > 1 && (
287274
<Popconfirm
@@ -290,9 +277,7 @@ function MultiModelDisplayInner({
290277
okText={t('common.confirm')}
291278
cancelText={t('common.cancel')}
292279
>
293-
<CardActionButton token={token}>
294-
<Trash2 size={13} />
295-
</CardActionButton>
280+
<Button type="text" size="small" danger icon={<Trash2 size={13} />} />
296281
</Popconfirm>
297282
)}
298283
{/* Use as context button */}
@@ -331,35 +316,6 @@ function MultiModelDisplayInner({
331316
);
332317
}
333318

334-
/** Small icon button with hover effect for card header actions */
335-
const CardActionButton = React.forwardRef<
336-
HTMLDivElement,
337-
{ token: ReturnType<typeof theme.useToken>['token']; onClick?: () => void; forceColor?: string; children: React.ReactNode }
338-
>(function CardActionButton({ token, onClick, forceColor, children }, ref) {
339-
const [hovered, setHovered] = useState(false);
340-
return (
341-
<div
342-
ref={ref}
343-
onClick={onClick}
344-
onMouseEnter={() => setHovered(true)}
345-
onMouseLeave={() => setHovered(false)}
346-
style={{
347-
display: 'flex',
348-
alignItems: 'center',
349-
justifyContent: 'center',
350-
width: 24,
351-
height: 24,
352-
borderRadius: token.borderRadiusSM,
353-
cursor: 'pointer',
354-
color: forceColor ?? (hovered ? token.colorText : token.colorTextQuaternary),
355-
backgroundColor: hovered ? token.colorFillSecondary : 'transparent',
356-
transition: 'all 0.2s',
357-
}}
358-
>
359-
{children}
360-
</div>
361-
);
362-
});
363319

364320
/**
365321
* Layout switcher row — rendered below ModelTags.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { useCallback } from 'react';
2+
import { Check, Copy } from 'lucide-react';
3+
import { Button, message, theme } from 'antd';
4+
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
5+
6+
export interface CopyButtonProps {
7+
/** Text to copy, or async function returning text to copy */
8+
text: string | (() => string | Promise<string>);
9+
/** Icon size in px (default: 14) */
10+
size?: number;
11+
/** Duration of success state in ms (default: 2000) */
12+
timeout?: number;
13+
/** If provided, shows message.success() with this string on copy */
14+
successMessage?: string;
15+
/** Called after successful copy */
16+
onSuccess?: () => void;
17+
/** Called on copy failure (clipboard error or text getter error) */
18+
onError?: (error: unknown) => void;
19+
/** Additional inline style */
20+
style?: React.CSSProperties;
21+
/** Additional className */
22+
className?: string;
23+
}
24+
25+
export const CopyButton = React.forwardRef<HTMLElement, CopyButtonProps>(
26+
function CopyButton(
27+
{ text, size = 14, timeout = 2000, successMessage, onSuccess, onError, style, className },
28+
ref,
29+
) {
30+
const { token } = theme.useToken();
31+
const { copy, isCopied } = useCopyToClipboard({ timeout });
32+
33+
const handleClick = useCallback(async () => {
34+
try {
35+
const value = typeof text === 'function' ? await text() : text;
36+
const ok = await copy(value);
37+
if (ok) {
38+
if (successMessage) message.success(successMessage);
39+
onSuccess?.();
40+
}
41+
} catch (e) {
42+
onError?.(e);
43+
}
44+
}, [text, copy, successMessage, onSuccess, onError]);
45+
46+
return (
47+
<Button
48+
ref={ref as React.Ref<HTMLButtonElement>}
49+
type="text"
50+
size="small"
51+
icon={isCopied ? <Check size={size} style={{ color: token.colorSuccess }} /> : <Copy size={size} />}
52+
onClick={handleClick}
53+
style={style}
54+
className={className}
55+
/>
56+
);
57+
},
58+
);

src/components/gateway/GatewayKeys.tsx

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,22 @@ import {
1010
Popconfirm,
1111
Typography,
1212
Alert,
13+
theme,
1314
} from 'antd';
14-
import { Plus, Trash2, Copy, Search } from 'lucide-react';
15-
import { writeText as clipboardWriteText } from '@tauri-apps/plugin-clipboard-manager';
15+
import { Plus, Trash2, Copy, Check, Search } from 'lucide-react';
1616
import { useGatewayStore } from '@/stores/gatewayStore';
17+
import { CopyButton } from '@/components/common/CopyButton';
18+
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
1719
import type { GatewayKey } from '@/types';
1820

1921
const { Text } = Typography;
2022

2123
export function GatewayKeys() {
2224
const { t } = useTranslation();
25+
const { token } = theme.useToken();
2326
const { keys, loading, fetchKeys, createKey, deleteKey, toggleKey, decryptKey } =
2427
useGatewayStore();
28+
const { copy: copyCreatedKey, isCopied: createdKeyCopied } = useCopyToClipboard();
2529

2630
const [createModalOpen, setCreateModalOpen] = useState(false);
2731
const [keyName, setKeyName] = useState('');
@@ -57,12 +61,6 @@ export function GatewayKeys() {
5761
}
5862
};
5963

60-
const handleCopyKey = async () => {
61-
if (createdKey) {
62-
await clipboardWriteText(createdKey);
63-
message.success(t('common.copySuccess'));
64-
}
65-
};
6664

6765
const handleCloseModal = () => {
6866
setCreateModalOpen(false);
@@ -114,19 +112,11 @@ export function GatewayKeys() {
114112
render: (_: unknown, record: GatewayKey) => (
115113
<div style={{ display: 'flex', gap: 4 }}>
116114
{record.has_encrypted_key && (
117-
<Button
118-
type="text"
119-
icon={<Copy size={14} />}
120-
size="small"
121-
onClick={async () => {
122-
try {
123-
const plainKey = await decryptKey(record.id);
124-
await clipboardWriteText(plainKey);
125-
message.success(t('common.copySuccess'));
126-
} catch (e) {
127-
message.error(String(e));
128-
}
129-
}}
115+
<CopyButton
116+
text={async () => decryptKey(record.id)}
117+
size={14}
118+
successMessage={t('common.copySuccess')}
119+
onError={(e) => message.error(String(e))}
130120
/>
131121
)}
132122
<Popconfirm
@@ -176,7 +166,12 @@ export function GatewayKeys() {
176166
footer={
177167
createdKey
178168
? [
179-
<Button key="copy" icon={<Copy size={16} />} onClick={handleCopyKey}>
169+
<Button key="copy" icon={createdKeyCopied ? <Check size={16} style={{ color: token.colorSuccess }} /> : <Copy size={16} />} onClick={async () => {
170+
if (createdKey) {
171+
const ok = await copyCreatedKey(createdKey);
172+
if (ok) message.success(t('common.copySuccess'));
173+
}
174+
}}>
180175
{t('gateway.copyKey')}
181176
</Button>,
182177
<Button key="close" type="primary" onClick={handleCloseModal}>

0 commit comments

Comments
 (0)