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(null); const [measuredMenuLayout, setMeasuredMenuLayout] = useState(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( '.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 ? (
event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} > {contextMenu.kind === 'blank' ? ( <> ) : contextMenu.kind === 'generation-dialog' ? ( ) : ( <>


{isImageSequenceLayer ? (
onExportContextLayer({ mode: 'sequence-with-preview', }) } > 序列帧导出(zip) onExportContextLayer({ mode: 'spine-json' }) } > Spine 导出(zip)
) : ( )}
{imageContextMenuLayer ? ( <> {isQuickEditSupportedLayer(imageContextMenuLayer) ? ( ) : null} {imageContextMenuLayer.assetKind === 'character' ? ( ) : null}
) : null} )}
) : null} {imageContextMenu && imageContextMenuLayer && !contextMenu ? (
event.stopPropagation()} > {isQuickEditSupportedLayer(imageContextMenuLayer) ? ( { onOpenQuickEditPanel(imageContextMenuLayer); onCloseImageContextMenu(); }} > 快速编辑 ) : null} { onOpenLayerMetadata(imageContextMenuLayer); onCloseImageContextMenu(); }} > 查看图片信息 {imageContextMenuLayer.assetKind === 'character' ? ( { onOpenCharacterAnimationPanel(imageContextMenuLayer); onCloseImageContextMenu(); }} > 生成动画 ) : null} onDeleteLayerById(imageContextMenuLayer.id)} > 删除图片
) : null} ); }