Files
Genarrative/src/components/image-editor/ImageCanvasContextMenusView.tsx
T
kdletters 1671d35995 修复画布Agent聊天生成体验
让画布 Agent 消息统一走后端规划与 LLM 回复
修复生成图片在聊天、画布和素材库中的展示与恢复
补齐右键删除、画布焦点、聊天滚轮和面板避让交互
同步 Agent 事件契约、文档和定向回归测试
2026-07-04 16:41:33 +08:00

583 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { clamp, CONTEXT_MENU_VIEWPORT_MARGIN } from './ImageCanvasEditorModel';
import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
type ImageCanvasContextMenusViewProps = {
viewport: CanvasViewport;
contextMenu: CanvasContextMenuState | null;
canvasClipboard: CanvasClipboard | null;
imageContextMenu: ImageContextMenuState | null;
imageContextMenuLayer: CanvasLayer | null;
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
onCopyContextLayers: (options?: { cut?: boolean }) => void;
onDuplicateContextLayers: () => void;
onMoveContextLayers: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
onGroupContextLayers: () => void;
onUngroupContextLayers: () => void;
onToggleContextLayerVisibility: () => void;
onToggleContextLayerLock: () => void;
onFlipContextLayers: (axis: 'x' | 'y') => void;
onExportContextLayer: (options?: {
mode?: 'spine-json' | 'sequence-with-preview';
}) => void;
onDeleteContextLayers: () => void;
onDeleteLayerById: (layerId: string | null) => void;
onCloseContextMenu: () => void;
onCloseImageContextMenu: () => void;
onUpdateScaleFromCenter: (nextScale: number) => void;
onFitLayers: () => void;
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
onOpenLayerMetadata: (layer: CanvasLayer) => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
};
type MeasuredMenuLayout = {
key: string;
x: number;
y: number;
submenuDirection: 'left' | 'right';
submenuVerticalDirection: 'down' | 'up';
};
export function ImageCanvasContextMenusView({
viewport,
contextMenu,
canvasClipboard: _canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
onPasteCanvasClipboard,
onCopyContextLayers,
onDuplicateContextLayers,
onMoveContextLayers,
onGroupContextLayers,
onUngroupContextLayers,
onToggleContextLayerVisibility,
onToggleContextLayerLock,
onFlipContextLayers,
onExportContextLayer,
onDeleteContextLayers,
onDeleteLayerById,
onCloseContextMenu,
onCloseImageContextMenu,
onUpdateScaleFromCenter,
onFitLayers,
onOpenQuickEditPanel,
onOpenLayerMetadata,
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const menuRef = useRef<HTMLDivElement | null>(null);
const [measuredMenuLayout, setMeasuredMenuLayout] =
useState<MeasuredMenuLayout | null>(null);
const closeMenus = useCallback(() => {
onCloseContextMenu();
onCloseImageContextMenu();
}, [onCloseContextMenu, onCloseImageContextMenu]);
const activeMenuLayoutKey = contextMenu
? [
'context',
contextMenu.kind,
contextMenu.kind === 'layer'
? contextMenu.layerId
: contextMenu.kind === 'generation-dialog'
? contextMenu.dialogId
: 'blank',
contextMenu.x,
contextMenu.y,
isImageSequenceLayer ? 'sequence' : 'single',
imageContextMenuLayer ? 'image-actions' : 'layer-actions',
imageContextMenuLayer?.assetKind ?? 'asset',
].join(':')
: imageContextMenu
? [
'image',
imageContextMenu.layerId,
imageContextMenu.x,
imageContextMenu.y,
imageContextMenuLayer?.assetKind ?? 'asset',
].join(':')
: null;
const activeMenuX = contextMenu?.x ?? imageContextMenu?.x ?? null;
const activeMenuY = contextMenu?.y ?? imageContextMenu?.y ?? null;
const currentMeasuredMenuLayout =
measuredMenuLayout?.key === activeMenuLayoutKey ? measuredMenuLayout : null;
const contextMenuClassName = [
'image-canvas-editor__context-menu',
currentMeasuredMenuLayout?.submenuDirection === 'left'
? 'image-canvas-editor__context-menu--submenu-left'
: null,
currentMeasuredMenuLayout?.submenuVerticalDirection === 'up'
? 'image-canvas-editor__context-menu--submenu-up'
: null,
]
.filter(Boolean)
.join(' ');
useLayoutEffect(() => {
const menuElement = menuRef.current;
if (
typeof window === 'undefined' ||
!activeMenuLayoutKey ||
activeMenuX === null ||
activeMenuY === null ||
!menuElement
) {
setMeasuredMenuLayout(null);
return;
}
const menuRect = menuElement.getBoundingClientRect();
const menuWidth = menuRect.width || menuElement.offsetWidth;
const menuHeight = menuRect.height || menuElement.offsetHeight;
const x = clamp(
activeMenuX,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerWidth - menuWidth - CONTEXT_MENU_VIEWPORT_MARGIN,
),
);
const y = clamp(
activeMenuY,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerHeight - menuHeight - CONTEXT_MENU_VIEWPORT_MARGIN,
),
);
const submenuElement = menuElement.querySelector<HTMLElement>(
'.image-canvas-editor__context-submenu-panel',
);
const submenuRect = submenuElement?.getBoundingClientRect();
const submenuWidth = submenuRect?.width || submenuElement?.offsetWidth || 0;
const submenuHeight =
submenuRect?.height || submenuElement?.offsetHeight || 0;
const submenuGap = 8;
const submenuVerticalOffset = 6;
const submenuDirection =
submenuWidth > 0 &&
x + menuWidth + submenuGap + submenuWidth >
window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN &&
x - submenuGap - submenuWidth >= CONTEXT_MENU_VIEWPORT_MARGIN
? 'left'
: 'right';
const submenuAnchorRect =
submenuElement?.parentElement?.getBoundingClientRect();
const submenuAnchorOffsetTop =
submenuAnchorRect && submenuElement
? submenuAnchorRect.top - menuRect.top
: 0;
const submenuAnchorHeight =
submenuAnchorRect?.height ||
submenuElement?.parentElement?.offsetHeight ||
0;
const submenuDefaultBottom =
y + submenuAnchorOffsetTop - submenuVerticalOffset + submenuHeight;
const submenuFlippedTop =
y +
submenuAnchorOffsetTop +
submenuAnchorHeight +
submenuVerticalOffset -
submenuHeight;
const submenuVerticalDirection =
submenuHeight > 0 &&
submenuDefaultBottom >
window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN &&
submenuFlippedTop >= CONTEXT_MENU_VIEWPORT_MARGIN
? 'up'
: 'down';
setMeasuredMenuLayout((previous) => {
if (
previous?.key === activeMenuLayoutKey &&
previous.x === x &&
previous.y === y &&
previous.submenuDirection === submenuDirection &&
previous.submenuVerticalDirection === submenuVerticalDirection
) {
return previous;
}
return {
key: activeMenuLayoutKey,
x,
y,
submenuDirection,
submenuVerticalDirection,
};
});
}, [activeMenuLayoutKey, activeMenuX, activeMenuY]);
useEffect(() => {
if (!contextMenu && !imageContextMenu) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current?.contains(event.target as Node)) {
return;
}
closeMenus();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenus();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', closeMenus, true);
window.addEventListener('resize', closeMenus);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', closeMenus, true);
window.removeEventListener('resize', closeMenus);
};
}, [closeMenus, contextMenu, imageContextMenu]);
return (
<>
{contextMenu ? (
<div
ref={menuRef}
className={contextMenuClassName}
role="menu"
aria-label={
contextMenu.kind === 'blank'
? '画布右键菜单'
: contextMenu.kind === 'generation-dialog'
? '生成器右键菜单'
: '图片功能面板'
}
style={{
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
top: currentMeasuredMenuLayout?.y ?? contextMenu.y,
}}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === 'blank' ? (
<>
<button
type="button"
role="menuitem"
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onUpdateScaleFromCenter(viewport.scale * 1.16);
onCloseContextMenu();
}}
>
放大
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onUpdateScaleFromCenter(viewport.scale * 0.86);
onCloseContextMenu();
}}
>
缩小
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onFitLayers();
onCloseContextMenu();
}}
>
显示画布所有元素
</button>
</>
) : contextMenu.kind === 'generation-dialog' ? (
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={onDeleteContextLayers}
>
删除
</button>
) : (
<>
<button
type="button"
role="menuitem"
onClick={() => onCopyContextLayers()}
>
复制
</button>
<button
type="button"
role="menuitem"
onClick={() => onCopyContextLayers({ cut: true })}
>
剪切
</button>
<button
type="button"
role="menuitem"
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
</button>
<button
type="button"
role="menuitem"
onClick={onDuplicateContextLayers}
>
创建副本
</button>
<hr />
<button
type="button"
role="menuitem"
onClick={() => onMoveContextLayers('up')}
>
上移一层
</button>
<button
type="button"
role="menuitem"
onClick={() => onMoveContextLayers('down')}
>
下移一层
</button>
<button
type="button"
role="menuitem"
onClick={() => onMoveContextLayers('top')}
>
置于顶层
</button>
<button
type="button"
role="menuitem"
onClick={() => onMoveContextLayers('bottom')}
>
移动至底层
</button>
<hr />
<button
type="button"
role="menuitem"
onClick={onGroupContextLayers}
>
创建组
</button>
<button
type="button"
role="menuitem"
onClick={onUngroupContextLayers}
>
解除组
</button>
<button
type="button"
role="menuitem"
onClick={onToggleContextLayerVisibility}
>
{contextShouldShowLayer ? '显示' : '隐藏'}
</button>
<button
type="button"
role="menuitem"
onClick={onToggleContextLayerLock}
>
{contextShouldUnlockLayer ? '解锁' : '锁定'}
</button>
<hr />
<button
type="button"
role="menuitem"
onClick={() => onFlipContextLayers('x')}
>
水平翻转
</button>
<button
type="button"
role="menuitem"
onClick={() => onFlipContextLayers('y')}
>
垂直翻转
</button>
{isImageSequenceLayer ? (
<div className="image-canvas-editor__context-submenu">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
className="image-canvas-editor__context-submenu-trigger"
>
<span>导出为</span>
<span aria-hidden="true">›</span>
</button>
<PlatformFloatingMenu
className="image-canvas-editor__context-submenu-panel"
label="动作导出选项"
placement="bottom-start"
style={{ margin: 0 }}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
onExportContextLayer({
mode: 'sequence-with-preview',
})
}
>
序列帧导出(zip)
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
onExportContextLayer({ mode: 'spine-json' })
}
>
Spine 导出(zip)
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
</div>
) : (
<button
type="button"
role="menuitem"
onClick={() => onExportContextLayer()}
>
导出为
</button>
)}
<hr />
{imageContextMenuLayer ? (
<>
<button
type="button"
role="menuitem"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onOpenLayerMetadata(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
查看图片信息
</button>
{imageContextMenuLayer.assetKind === 'character' ? (
<button
type="button"
role="menuitem"
onClick={() => {
onOpenCharacterAnimationPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
生成动画
</button>
) : null}
<hr />
</>
) : null}
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={onDeleteContextLayers}
>
删除
</button>
</>
)}
</div>
) : null}
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
<div
ref={menuRef}
className={contextMenuClassName}
style={{
left: currentMeasuredMenuLayout?.x ?? imageContextMenu.x,
top: currentMeasuredMenuLayout?.y ?? imageContextMenu.y,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenLayerMetadata(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
查看图片信息
</PlatformFloatingMenuItem>
{imageContextMenuLayer.assetKind === 'character' ? (
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenCharacterAnimationPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
生成动画
</PlatformFloatingMenuItem>
) : null}
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => onDeleteLayerById(imageContextMenuLayer.id)}
>
删除图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
</div>
) : null}
</>
);
}