Files
Genarrative/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx
T
k88936 9bb4942ae7
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 31s
Project CI / Native shell tests (push) Successful in 2m25s
Project CI / Backend tests (push) Successful in 3m7s
Editor agent 右键菜单支持复制, 引用,下载 (#95)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/95
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-22 18:00:27 +08:00

234 lines
6.5 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
contextAssetMediaSrc,
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
? '下载中'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用中'
: '复制中';
}
if (resultAction !== action) {
return idleLabel;
}
if (result === 'success') {
return action === EditorAgentRightClickAction.DownloadAsset
? '已下载'
: action === EditorAgentRightClickAction.ReferenceImage
? '已引用'
: '已复制';
}
if (result === 'error') {
return action === EditorAgentRightClickAction.DownloadAsset
? '下载失败'
: action === EditorAgentRightClickAction.ReferenceImage
? '引用失败'
: '复制失败';
}
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 handleContextMenu = (event: MouseEvent) => {
if (menuRef.current?.contains(event.target as Node)) {
event.preventDefault();
}
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('contextmenu', handleContextMenu, true);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('contextmenu', handleContextMenu, true);
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,
zIndex: 60,
}}
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' ? (
<>
{contextAssetMediaSrc(target.asset).trim() ? (
<button
type="button"
role="menuitem"
disabled={pendingAction !== null}
onClick={() =>
onAction(EditorAgentRightClickAction.ReferenceImage)
}
>
{actionLabel({
action: EditorAgentRightClickAction.ReferenceImage,
idleLabel: '引用',
pendingAction,
resultAction,
result,
})}
</button>
) : null}
<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,
);
}