8abeb333bb
- 使用白名单控制快速编辑功能的显示 - 同步浮动工具栏、右键菜单、打开入口和提交门禁 - 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑 - 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验 其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画 > 布迁移摘要同步及最终零残留复核。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
611 lines
20 KiB
TypeScript
611 lines
20 KiB
TypeScript
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 { isQuickEditSupportedLayer } 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 ? (
|
||
<>
|
||
{isQuickEditSupportedLayer(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">
|
||
{isQuickEditSupportedLayer(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}
|
||
</>
|
||
);
|
||
}
|