right click copy/dowload option

This commit is contained in:
2026-07-20 17:02:24 +08:00
parent eb608ac6f2
commit ac0c804da8
8 changed files with 753 additions and 54 deletions
@@ -1,6 +1,6 @@
# 画布Agent对话面板
日期:`2026-07-16`
日期:`2026-07-20`
## 定位与边界
@@ -114,11 +114,12 @@
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体。右键动作由消息气泡的 `useRightClickMenu` 内部执行,不向上层暴露消息文本、素材地址或私有对象键。
不做(明确排除,防止后人补齐):
- 点赞/点踩反馈按钮;
- 消息复制、分享/导出对话;
- 分享/导出整段对话;
- Agent 模式切换下拉(固定单一 Agent);
- 语音输入、@引用、多 Agent 协作;
- Lovart 的积分/加速档位显示(泥点扣费只在生成动作上体现)。
@@ -156,3 +157,4 @@
- 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。
- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;轮询到任务终态并完成会话懒回填后统一刷新工程快照和素材库。
- 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。
- 对话正文右键菜单只复制当前气泡展示的完整文本,隐藏的内部 system 文本不得进入菜单;素材右键菜单优先于正文菜单,私有素材继续通过既有读取链路换签或代理下载,不复制会过期的临时链接。
@@ -3,16 +3,33 @@ import { Image as ImageIcon, X } from 'lucide-react';
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import type { RightClickMenuHandler } from './common.ts';
function AttachmentChip({
attachment,
onRemove,
onRightClickMenu,
}: {
attachment: EditorAgentAttachmentRef;
onRemove?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const label = attachment.label?.trim() || attachment.referenceId;
return (
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
<span
className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
mediaType: 'image',
source: attachment.imageSrc,
objectKey: attachment.objectKey,
suggestedFileName: label,
})
: undefined
}
>
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
{onRemove ? (
@@ -1,12 +1,25 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import { MessageBubble } from './MessageBubble.tsx';
vi.mock('@/src/services/assetReadUrlService.ts', () => ({
getSignedAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
readAssetBytes: vi.fn(),
resolveAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
shouldResolveAssetReadUrl: vi.fn(() => false),
}));
vi.mock('@/src/services/clipboard.ts', () => ({
copyTextToClipboard: vi.fn(),
}));
function renderMessage(message: EditorAgentMessage) {
return render(
<MessageBubble
@@ -19,6 +32,11 @@ function renderMessage(message: EditorAgentMessage) {
}
describe('MessageBubble', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(copyTextToClipboard).mockResolvedValue(true);
});
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
renderMessage({
id: 2,
@@ -50,4 +68,161 @@ describe('MessageBubble', () => {
expect(container.childElementCount).toBe(0);
});
it('copies the complete visible message and only exposes the enum action', async () => {
renderMessage({
id: 4,
role: 'assistant',
text: '第一行\n第二行',
attachments: [],
toolCall: null,
createdAt: '2026-07-20T00:00:00Z',
});
fireEvent.contextMenu(screen.getByLabelText('Agent消息'), {
clientX: 30,
clientY: 40,
});
fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' }));
await waitFor(() =>
expect(copyTextToClipboard).toHaveBeenCalledWith('第一行\n第二行'),
);
expect(
await screen.findByRole('menuitem', { name: '已复制' }),
).toBeTruthy();
});
it('copies an attached WebP as PNG without opening the message text menu', async () => {
const clipboardWrite = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write: clipboardWrite },
});
const ClipboardItemMock = vi.fn(function (
this: { items: Record<string, Blob> },
items: Record<string, Blob>,
) {
this.items = items;
});
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
value: ClipboardItemMock,
});
const bitmapClose = vi.fn();
Object.defineProperty(globalThis, 'createImageBitmap', {
configurable: true,
value: vi.fn().mockResolvedValue({
width: 16,
height: 9,
close: bitmapClose,
}),
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback) => callback(new Blob(['png'], { type: 'image/png' })),
);
vi.mocked(readAssetBytes).mockResolvedValue({
blob: vi
.fn()
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
} as unknown as Response);
const { container } = renderMessage({
id: 5,
role: 'user',
text: '参考这张图',
attachments: [
{
source: 'canvas_resource',
referenceId: 'resource-1',
objectKey: 'editor/private.webp',
imageSrc: '/generated-editor-images/private.webp',
label: '参考图.webp',
},
],
toolCall: null,
createdAt: '2026-07-20T00:00:00Z',
});
fireEvent.contextMenu(container.querySelector('.group')!, {
clientX: 30,
clientY: 40,
});
expect(screen.getByRole('menu', { name: '消息素材右键菜单' })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: '复制文本' })).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '复制图片' }));
await waitFor(() => expect(clipboardWrite).toHaveBeenCalledTimes(1));
expect(readAssetBytes).toHaveBeenCalledWith(
'/generated-editor-images/private.webp',
{ objectKey: 'editor/private.webp' },
);
expect(ClipboardItemMock).toHaveBeenCalledWith({
'image/png': expect.any(Blob),
});
expect(bitmapClose).toHaveBeenCalledTimes(1);
});
it('downloads generated video with the inferred extension', async () => {
const createObjectURL = vi.fn(() => 'blob:agent-video');
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.mocked(readAssetBytes).mockResolvedValue({
blob: vi
.fn()
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })),
} as unknown as Response);
const { container } = renderMessage({
id: 6,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_video',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [],
videos: [
{
resourceId: 'video-1',
objectKey: 'editor/video.mp4',
videoSrc: '/generated-editor-videos/video.mp4',
},
],
audios: [],
},
createdAt: '2026-07-20T00:00:00Z',
});
await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
const video = container.querySelector('video');
fireEvent.contextMenu(video!.parentElement!, {
clientX: 30,
clientY: 40,
});
expect(screen.queryByRole('menuitem', { name: '复制图片' })).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '下载视频' }));
await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:agent-video');
});
});
@@ -7,6 +7,9 @@ import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversa
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
import { useRightClickMenu } from './useRightClickMenu.ts';
function messageRoleLabel(role: EditorAgentMessage['role']) {
if (role === 'user') {
return '你';
@@ -54,6 +57,12 @@ export function MessageBubble({
onCancelToolCall,
onJobCompleted,
}: MessageBubbleProps) {
const {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
} = useRightClickMenu();
const systemErrorText =
message.role === 'system' &&
!message.toolCall &&
@@ -61,7 +70,16 @@ export function MessageBubble({
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
: null;
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
const visibleText = systemErrorText ?? (!isSystem ? message.text : '');
if (
message.role === 'system' &&
!message.toolCall &&
systemErrorText === null
) {
return null;
}
if (
@@ -81,56 +99,78 @@ export function MessageBubble({
);
}
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isSystemError = systemErrorText !== null;
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
>
<div
className={
isSystem && !isSystemError
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: isSystemError || message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
<>
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={
isSystemError
? 'Agent错误'
: isSystem
? 'Agent操作'
: `${messageRoleLabel(message.role)}消息`
}
onContextMenu={
visibleText.trim()
? (event) =>
openRightClickMenu(event, { kind: 'text', text: visibleText })
: undefined
}
>
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
<div className="whitespace-pre-wrap break-words">
{systemErrorText ?? message.text}
</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
/>
) : null}
</div>
</article>
<div
className={
isSystem && !isSystemError
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: isSystemError || message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
}
>
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
<div className="whitespace-pre-wrap break-words">
{systemErrorText ?? message.text}
</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
))}
</div>
) : null}
{message.toolCall ? (
<ToolCallView
toolCall={message.toolCall}
onJobCompleted={onJobCompleted}
onRightClickMenu={(event, asset) =>
openRightClickMenu(event, { kind: 'asset', asset })
}
/>
) : null}
</div>
</article>
{rightClickMenu ? (
<MessageBubbleRightClickMenu
x={rightClickMenu.x}
y={rightClickMenu.y}
target={rightClickMenu.target}
pendingAction={rightClickMenu.pendingAction}
resultAction={rightClickMenu.resultAction}
result={rightClickMenu.result}
onAction={(action) => void runRightClickAction(action)}
onClose={closeRightClickMenu}
/>
) : null}
</>
);
}
@@ -0,0 +1,194 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
type EditorAgentContextAsset,
EditorAgentRightClickAction,
type RightClickMenuTarget,
} from './common.ts';
type MessageBubbleRightClickMenuProps = {
x: number;
y: number;
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
onAction: (action: EditorAgentRightClickAction) => void;
onClose: () => void;
};
const VIEWPORT_MARGIN = 8;
function actionLabel({
action,
idleLabel,
pendingAction,
resultAction,
result,
}: {
action: EditorAgentRightClickAction;
idleLabel: string;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
}) {
if (pendingAction === action) {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载中'
: '复制中';
}
if (resultAction !== action) {
return idleLabel;
}
if (result === 'success') {
return action === EditorAgentRightClickAction.DownloadAsset
? '已下载'
: '已复制';
}
if (result === 'error') {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载失败'
: '复制失败';
}
return idleLabel;
}
function assetDownloadLabel(asset: EditorAgentContextAsset) {
return `下载${
asset.mediaType === 'image'
? '图片'
: asset.mediaType === 'video'
? '视频'
: '音频'
}`;
}
export function MessageBubbleRightClickMenu({
x,
y,
target,
pendingAction,
resultAction,
result,
onAction,
onClose,
}: MessageBubbleRightClickMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu || typeof window === 'undefined') {
return;
}
const rect = menu.getBoundingClientRect();
setPosition({
x: Math.min(
Math.max(x, VIEWPORT_MARGIN),
Math.max(
VIEWPORT_MARGIN,
window.innerWidth - rect.width - VIEWPORT_MARGIN,
),
),
y: Math.min(
Math.max(y, VIEWPORT_MARGIN),
Math.max(
VIEWPORT_MARGIN,
window.innerHeight - rect.height - VIEWPORT_MARGIN,
),
),
});
}, [target.kind, x, y]);
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
};
}, [onClose]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
role="menu"
aria-label={target.kind === 'text' ? '消息右键菜单' : '消息素材右键菜单'}
style={{ left: position?.x ?? x, top: position?.y ?? y }}
onContextMenu={(event) => event.preventDefault()}
>
{target.kind === 'text' ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyText)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyText,
idleLabel: '复制文本',
pendingAction,
resultAction,
result,
})}
</button>
) : (
<>
{target.asset.mediaType === 'image' ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
>
{actionLabel({
action: EditorAgentRightClickAction.CopyImage,
idleLabel: '复制图片',
pendingAction,
resultAction,
result,
})}
</button>
) : null}
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() => onAction(EditorAgentRightClickAction.DownloadAsset)}
>
{actionLabel({
action: EditorAgentRightClickAction.DownloadAsset,
idleLabel: assetDownloadLabel(target.asset),
pendingAction,
resultAction,
result,
})}
</button>
</>
)}
</div>,
document.body,
);
}
@@ -8,12 +8,16 @@ import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
import { getExternalGenerationJobStatus } from '@/src/services/external-generation';
import type { RightClickMenuHandler } from './common.ts';
function ToolCallView({
toolCall,
onJobCompleted,
onRightClickMenu,
}: {
toolCall: EditorAgentToolCall;
onJobCompleted?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const videos = toolCall.videos ?? [];
const audios = toolCall.audios ?? [];
@@ -69,6 +73,8 @@ function ToolCallView({
disposed = true;
if (timeoutId) clearTimeout(timeoutId);
};
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
const isCancelled = displayStatus === 'cancelled';
const isCompleted = displayStatus === 'completed';
@@ -107,6 +113,17 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
mediaType: 'image',
source: image.imageSrc,
objectKey: image.objectKey,
suggestedFileName: `Agent生成图片-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
@@ -125,6 +142,17 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
mediaType: 'video',
source: video.videoSrc,
objectKey: video.objectKey,
suggestedFileName: `Agent生成视频-${index + 1}`,
})
: undefined
}
>
<ResolvedAssetVideo
src={video.videoSrc}
@@ -148,6 +176,17 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
onContextMenu={
onRightClickMenu
? (event) =>
onRightClickMenu(event, {
mediaType: 'audio',
source: audio.audioSrc,
objectKey: audio.objectKey,
suggestedFileName: `Agent生成音频-${index + 1}`,
})
: undefined
}
>
<Volume2
className="h-4 w-4 shrink-0 text-slate-500"
@@ -1,5 +1,29 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
export enum EditorAgentRightClickAction {
CopyText = 'copy_text',
CopyImage = 'copy_image',
DownloadAsset = 'download_asset',
}
export type EditorAgentContextAsset = {
mediaType: 'image' | 'video' | 'audio';
source: string;
objectKey?: string | null;
suggestedFileName: string;
};
export type RightClickMenuTarget =
| { kind: 'text'; text: string }
| { kind: 'asset'; asset: EditorAgentContextAsset };
export type RightClickMenuHandler = (
event: ReactMouseEvent<HTMLElement>,
asset: EditorAgentContextAsset,
) => void;
export function attachmentKey(attachment: EditorAgentAttachmentRef) {
return `${attachment.source}:${attachment.referenceId}`;
}
@@ -0,0 +1,208 @@
import {
type MouseEvent as ReactMouseEvent,
useCallback,
useState,
} from 'react';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import {
type EditorAgentContextAsset,
EditorAgentRightClickAction,
type RightClickMenuTarget,
} from './common.ts';
type RightClickMenuState = {
x: number;
y: number;
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
};
const MIME_FILE_EXTENSIONS: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'video/mp4': 'mp4',
'video/webm': 'webm',
'audio/mpeg': 'mp3',
'audio/mp4': 'm4a',
'audio/ogg': 'ogg',
'audio/wav': 'wav',
};
function sanitizeDownloadName(value: string, mimeType: string) {
const normalized = value
.trim()
.replace(/[<>:"/\\|?*]/gu, '-')
.replace(/\p{Cc}/gu, '-')
.replace(/[. ]+$/u, '')
.slice(0, 100);
const baseName = normalized || 'Agent素材';
if (/\.[a-z0-9]{2,5}$/iu.test(baseName)) {
return baseName;
}
const extension = MIME_FILE_EXTENSIONS[mimeType.toLowerCase()] ?? '';
return extension ? `${baseName}.${extension}` : baseName;
}
async function convertImageBlobToPng(blob: Blob) {
if (blob.type.toLowerCase() === 'image/png') {
return blob;
}
if (
typeof createImageBitmap !== 'function' ||
typeof document === 'undefined'
) {
throw new Error('当前浏览器不支持复制此图片格式');
}
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('图片转换失败');
}
context.drawImage(bitmap, 0, 0);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((pngBlob) => {
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error('图片转换失败'));
}
}, 'image/png');
});
} finally {
bitmap.close();
}
}
async function copyAssetImage(asset: EditorAgentContextAsset) {
if (
typeof navigator === 'undefined' ||
typeof navigator.clipboard?.write !== 'function' ||
typeof ClipboardItem === 'undefined'
) {
return false;
}
try {
const response = await readAssetBytes(asset.source, {
objectKey: asset.objectKey,
});
const pngBlob = await convertImageBlobToPng(await response.blob());
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);
return true;
} catch {
return false;
}
}
async function downloadAsset(asset: EditorAgentContextAsset) {
if (
typeof document === 'undefined' ||
typeof URL.createObjectURL !== 'function'
) {
return false;
}
try {
const response = await readAssetBytes(asset.source, {
objectKey: asset.objectKey,
});
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = sanitizeDownloadName(asset.suggestedFileName, blob.type);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
return true;
} catch {
return false;
}
}
export function useRightClickMenu() {
const [rightClickMenu, setRightClickMenu] =
useState<RightClickMenuState | null>(null);
const closeRightClickMenu = useCallback(() => {
setRightClickMenu(null);
}, []);
const openRightClickMenu = useCallback(
(event: ReactMouseEvent<HTMLElement>, target: RightClickMenuTarget) => {
event.preventDefault();
event.stopPropagation();
setRightClickMenu({
x: event.clientX,
y: event.clientY,
target,
pendingAction: null,
resultAction: null,
result: null,
});
},
[],
);
const runRightClickAction = useCallback(
async (action: EditorAgentRightClickAction) => {
if (!rightClickMenu || rightClickMenu.pendingAction) {
return;
}
const target = rightClickMenu.target;
setRightClickMenu((current) =>
current?.target === target
? {
...current,
pendingAction: action,
resultAction: null,
result: null,
}
: current,
);
const succeeded =
action === EditorAgentRightClickAction.CopyText &&
target.kind === 'text'
? await copyTextToClipboard(target.text)
: action === EditorAgentRightClickAction.CopyImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image'
? await copyAssetImage(target.asset)
: action === EditorAgentRightClickAction.DownloadAsset &&
target.kind === 'asset'
? await downloadAsset(target.asset)
: false;
setRightClickMenu((current) =>
current?.target === target && current.pendingAction === action
? {
...current,
pendingAction: null,
resultAction: action,
result: succeeded ? 'success' : 'error',
}
: current,
);
},
[rightClickMenu],
);
return {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
};
}