Files
Genarrative/src/components/image-editor/ImageCanvasContextMenusView.tsx
T
kdletters 7f18f7ce04
Project CI / Frontend tests (push) Failing after 21s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m44s
Project CI / Native shell tests (push) Successful in 10m42s
修复画布副本持久化与素材类型覆盖
复制布局复用项目资源并区分资源登记状态
新增图层素材类型覆盖并保持资源默认类型
补齐结构化存储迁移、绑定与权威类型校验
同步前后端回归测试和画布持久化文档
2026-08-04 18:06:15 +08:00

611 lines
20 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';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
type ImageCanvasContextMenusViewProps = {
viewport: CanvasViewport;
contextMenu: CanvasContextMenuState | null;
canvasClipboard: CanvasClipboard | null;
imageContextMenu: ImageContextMenuState | null;
imageContextMenuLayer: CanvasLayer | null;
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canCopyContextLayers: boolean;
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
isCanvasClipboardCopyBlocked: 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,
canCopyContextLayers,
contextLayerCopyBlockReason = null,
isCanvasClipboardCopyBlocked,
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 layerCopyBlockedTitle =
contextLayerCopyBlockReason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: contextLayerCopyBlockReason === 'pending-resource'
? '素材仍在保存,请稍后重试'
: contextLayerCopyBlockReason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: undefined;
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"
disabled={isCanvasClipboardCopyBlocked}
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"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers()}
>
复制
</button>
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers({ cut: true })}
>
剪切
</button>
<button
type="button"
role="menuitem"
disabled={isCanvasClipboardCopyBlocked}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
</button>
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
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 ? (
<>
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
<button
type="button"
role="menuitem"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
) : null}
<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">
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
) : null}
<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}
</>
);
}