Files
Genarrative/src/components/image-editor/ImageCanvasContextMenusView.tsx
T
kdletters 3cbc33bd69 优化画布快速编辑参数与替换规则
快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型
仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理
按用户选定分辨率替换当前图层并保持图层中心位置
补齐后端真实图层校验、移动端避让、定向测试与项目文档
2026-07-13 18:48:38 +08:00

588 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';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
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 ? (
<>
{!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}
</>
);
}