import { CanvasWorld as SharedCanvasWorld, LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, Info, Megaphone, Mountain, Music, Pause, PersonStanding, Play, Sparkles, UserRound, Volume2, VolumeX, } from 'lucide-react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, RefObject, SetStateAction, } from 'react'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { CANVAS_WORLD_SIZE, isCanvasAssetKindOverrideCompatible, resolveLayerResourceAssetKind, } from './ImageCanvasEditorModel'; import type { CanvasAssetKind, CanvasGenerationDialogState, CanvasLayer, CanvasMarqueeState, CanvasViewport, CropExpandPanelState, CropExpandResizeHandle, GenerateDialogState, SnapGuide, } from './ImageCanvasEditorTypes'; import { getGenerationFrameAriaLabel, getGenerationFrameLabel, } from './ImageCanvasGenerationModel'; import { getCanvasMediaPreviewClassName, getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) { switch (dialog.mode) { case 'character': return { frameClassName: 'image-canvas-editor__generation-frame--character', badgeClassName: 'character', badgeLabel: '角色', icon: , labelIcon: , }; case 'character-animation': return { frameClassName: 'image-canvas-editor__generation-frame--character-animation', badgeClassName: 'character-animation', badgeLabel: '动作', icon: , labelIcon: , }; case 'spec': return { frameClassName: 'image-canvas-editor__generation-frame--spec', badgeClassName: 'spec', badgeLabel: '规范', icon: , labelIcon: , }; case 'icon': return { frameClassName: 'image-canvas-editor__generation-frame--icon', badgeClassName: 'icon', badgeLabel: '图标', icon: , labelIcon: , }; case 'ui-design': return { frameClassName: 'image-canvas-editor__generation-frame--ui-design', badgeClassName: 'ui-design', badgeLabel: 'UI设计', icon: , labelIcon: , }; case 'scene': return { frameClassName: 'image-canvas-editor__generation-frame--scene', badgeClassName: 'scene', badgeLabel: '场景', icon: , labelIcon: , }; case 'quick-edit': return { frameClassName: 'image-canvas-editor__generation-frame--quick-edit', badgeClassName: 'quick-edit', badgeLabel: '快编', icon: , labelIcon: , }; case 'publication': return { frameClassName: 'image-canvas-editor__generation-frame--publication', badgeClassName: 'publication', badgeLabel: '宣发', icon: , labelIcon: , }; case 'video': return { frameClassName: 'image-canvas-editor__generation-frame--video', badgeClassName: 'video', badgeLabel: '视频', icon: , labelIcon: , }; case 'audio-sound-effect': return { frameClassName: 'image-canvas-editor__generation-frame--audio-sound-effect', badgeClassName: 'sound-effect', badgeLabel: '音效', icon: , labelIcon: , }; case 'audio-background-music': return { frameClassName: 'image-canvas-editor__generation-frame--audio-background-music', badgeClassName: 'background-music', badgeLabel: '背景音乐', icon: , labelIcon: , }; default: return { frameClassName: '', badgeClassName: null, badgeLabel: null, icon: , labelIcon: , }; } } function getInverseViewportScale(viewport: CanvasViewport) { if (!Number.isFinite(viewport.scale) || viewport.scale <= 0) { return 1; } return 1 / viewport.scale; } function buildInverseScaleStyle( inverseScale: number, ): CSSProperties & Record { return { '--image-canvas-editor-inverse-scale': String(inverseScale), '--image-canvas-editor-corner-offset': `calc(0.35rem * ${inverseScale})`, '--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`, '--image-canvas-editor-beside-kind-offset': `calc(1.95rem * ${inverseScale})`, '--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`, }; } function isCanvasMediaContainerLayer(layer: CanvasLayer) { return ( layer.mediaType === 'audio' || layer.mediaType === 'video' || layer.mediaType === 'image-sequence' ); } function getLayerMetadataMediaLabel(layer: CanvasLayer) { if (layer.mediaType === 'audio') { return '音频'; } if (layer.mediaType === 'video') { return '视频'; } if (layer.mediaType === 'image-sequence') { return '序列帧'; } return '图片'; } const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ value: CanvasAssetKind | null; label: string; className: string; }> = [ { value: null, label: '未知', className: 'unknown' }, { value: 'spec', label: '规范', className: 'spec' }, { value: 'character', label: '角色', className: 'character' }, { value: 'character-animation', label: '动作', className: 'character-animation', }, { value: 'icon', label: '图标', className: 'icon' }, { value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' }, { value: 'icon-spec', label: '图标规范', className: 'icon-spec' }, { value: 'publication-material', label: '宣发素材', className: 'publication-material', }, { value: 'ui-design', label: 'UI设计', className: 'ui-design' }, { value: 'scene', label: '场景', className: 'scene' }, { value: 'video', label: '视频', className: 'video' }, { value: 'sound-effect', label: '音效', className: 'sound-effect' }, { value: 'background-music', label: '背景音乐', className: 'background-music', }, ]; const UNKNOWN_ASSET_KIND_TAG_OPTION = CANVAS_ASSET_KIND_TAG_OPTIONS[0] as { value: null; label: string; className: string; }; function getLayerKindTagMeta(layer: CanvasLayer) { const knownKind = layer.assetKind ? CANVAS_ASSET_KIND_TAG_OPTIONS.find( (option) => option.value === layer.assetKind, ) : null; if (knownKind) { return knownKind; } return UNKNOWN_ASSET_KIND_TAG_OPTION; } export type ImageCanvasWorldViewProps = { viewport: CanvasViewport; snapGuide: SnapGuide | null; layers: CanvasLayer[]; selectedLayerIds: string[]; hoveredLayerId: string | null; canvasMarquee: CanvasMarqueeState | null; canvasGenerationDialogs: CanvasGenerationDialogState[]; generateDialog: GenerateDialogState | null; cropExpandPanel: CropExpandPanelState | null; cropExpandSourceLayer: CanvasLayer | null; generationComposerStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; onLayerPointerDown: ( event: ReactPointerEvent, layer: CanvasLayer, ) => void; onLayerClick: ( event: ReactMouseEvent, layer: CanvasLayer, ) => void; onSelectLayer: (layerId: string | null) => void; onLayerContextMenu: ( event: ReactMouseEvent, layer: CanvasLayer, ) => void; onLayerMouseEnter: (layerId: string) => void; onLayerMouseLeave: (layerId: string) => void; onOpenLayerMetadata: (layer: CanvasLayer) => void; onUpdateLayerAssetKind: ( layerId: string, assetKind: CanvasAssetKind | null, ) => void; onGenerationFramePointerDown: ( event: ReactPointerEvent, dialog: CanvasGenerationDialogState, ) => void; onGenerationFrameContextMenu: ( event: ReactMouseEvent, dialog: CanvasGenerationDialogState, ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onCropExpandHandlePointerDown: ( event: ReactPointerEvent, handle: CropExpandResizeHandle, ) => void; }; type ImageCanvasLayerHandlers = Pick< ImageCanvasWorldViewProps, | 'onLayerPointerDown' | 'onLayerClick' | 'onSelectLayer' | 'onLayerContextMenu' | 'onLayerMouseEnter' | 'onLayerMouseLeave' | 'onOpenLayerMetadata' | 'onUpdateLayerAssetKind' > & { setOpenLayerKindMenuId: (value: SetStateAction) => void; }; const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); const CROP_EXPAND_HANDLES: Array<{ value: CropExpandResizeHandle; label: string; }> = [ { value: 'top-left', label: '左上角' }, { value: 'top', label: '上边' }, { value: 'top-right', label: '右上角' }, { value: 'right', label: '右边' }, { value: 'bottom-right', label: '右下角' }, { value: 'bottom', label: '下边' }, { value: 'bottom-left', label: '左下角' }, { value: 'left', label: '左边' }, ]; function stopMediaEventPropagation( event: ReactMouseEvent | ReactPointerEvent, ) { event.stopPropagation(); } function selectMediaLayerWithoutDrag( event: ReactPointerEvent, onSelectLayer: () => void, ) { event.stopPropagation(); onSelectLayer(); } function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); event.currentTarget.click(); } function handleLayerTagKeyboardActivation( event: ReactKeyboardEvent, ) { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); event.stopPropagation(); event.currentTarget.click(); } function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { if (event.key === 'Backspace' || event.key === 'Delete') { return; } event.stopPropagation(); } function isImageAlreadyDecoded(image: HTMLImageElement | null) { return Boolean(image?.complete && image.naturalWidth > 0); } function getLayerMetricLabel(layer: CanvasLayer) { return `${Math.round(layer.originalWidth)} x ${Math.round( layer.originalHeight, )} px`; } function getAudioLayerIcon(layer: CanvasLayer) { return layer.assetKind === 'background-music' ? ( ) : ( ); } function formatAudioControlTime(seconds: number) { if (!Number.isFinite(seconds) || seconds <= 0) { return '0:00'; } const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60) .toString() .padStart(2, '0'); return `${minutes}:${remainingSeconds}`; } function ImageCanvasAudioLayerCard({ layer, isHovered, onSelectLayer, }: { layer: CanvasLayer; isHovered: boolean; onSelectLayer: () => void; }) { const audioRef = useRef(null); const previewClassName = getCanvasMediaPreviewClassName( getCanvasMediaPreviewMarker(layer), ); const [isPlaying, setIsPlaying] = useState(false); const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, { objectKey: layer.objectKey, refreshKey: layer.taskId ?? layer.resourceId, }); const prompt = layer.prompt?.trim(); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); const showPlaybackButton = isPlaying || isHovered; const controlDuration = duration > 0 ? duration : 0; const safeCurrentTime = controlDuration > 0 ? Math.min(currentTime, controlDuration) : 0; useEffect(() => { setIsPlaying(false); setCurrentTime(0); setDuration(0); }, [layer.src]); const togglePlayback = async () => { const audio = audioRef.current; if (!audio) { return; } if (isPlaying) { audio.pause(); return; } try { await audio.play(); } catch { setIsPlaying(false); } }; const handleSeekChange = (nextValue: string) => { const nextTime = Number(nextValue); if (!Number.isFinite(nextTime)) { return; } const clampedTime = controlDuration > 0 ? Math.min(Math.max(nextTime, 0), controlDuration) : Math.max(nextTime, 0); if (audioRef.current) { audioRef.current.currentTime = clampedTime; } setCurrentTime(clampedTime); }; const handleVolumeChange = (nextValue: string) => { const nextVolume = Number(nextValue); if (!Number.isFinite(nextVolume)) { return; } const clampedVolume = Math.min(Math.max(nextVolume, 0), 1); if (audioRef.current) { audioRef.current.volume = clampedVolume; audioRef.current.muted = clampedVolume === 0; } setVolume(clampedVolume); setIsMuted(clampedVolume === 0); }; const toggleMute = () => { const nextMuted = !isMuted; if (audioRef.current) { audioRef.current.muted = nextMuted; } setIsMuted(nextMuted); }; return ( { event.stopPropagation(); void togglePlayback(); }} onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer) } > {showPlaybackButton ? ( isPlaying ? ( ) : ( ) ) : ( getAudioLayerIcon(layer) )} {prompt ? ( {prompt} ) : null} selectMediaLayerWithoutDrag(event, onSelectLayer) } > selectMediaLayerWithoutDrag(event, onSelectLayer) } onLoadedMetadata={(event) => { const nextDuration = event.currentTarget.duration; setDuration( Number.isFinite(nextDuration) && nextDuration > 0 ? nextDuration : 0, ); }} onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime) } onVolumeChange={(event) => { setVolume(event.currentTarget.volume); setIsMuted(event.currentTarget.muted); }} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)} /> {formatAudioControlTime(safeCurrentTime)} handleSeekChange(event.currentTarget.value)} /> {formatAudioControlTime(controlDuration)} { event.stopPropagation(); toggleMute(); }} onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer) } > {isMuted ? ( ) : ( )} handleVolumeChange(event.currentTarget.value)} /> ); } function ImageCanvasVideoLayer({ layer, mediaTransform, }: { layer: CanvasLayer; mediaTransform?: string; }) { const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, { objectKey: layer.objectKey, refreshKey: layer.taskId ?? layer.resourceId, }); const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl( layer.thumbnailSrc, { refreshKey: `${layer.taskId ?? layer.resourceId}:poster`, }, ); const [showGeneratedPreview, setShowGeneratedPreview] = useState( !layer.thumbnailSrc, ); const previewClassName = getCanvasMediaPreviewClassName( getCanvasMediaPreviewMarker(layer), ); useEffect(() => { setShowGeneratedPreview(!layer.thumbnailSrc); }, [layer.src, layer.thumbnailSrc]); return ( <> setShowGeneratedPreview(false)} onPlay={() => setShowGeneratedPreview(false)} onClick={(event) => event.stopPropagation()} onPointerDown={(event) => { if (event.target !== event.currentTarget) { event.stopPropagation(); } }} /> > ); } function ImageCanvasImageLayer({ layer, mediaTransform, }: { layer: CanvasLayer; mediaTransform?: string; }) { const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl( layer.src, { objectKey: layer.objectKey, refreshKey: layer.taskId ?? layer.resourceId, }, ); const imageRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { setIsLoaded(isImageAlreadyDecoded(imageRef.current)); }, [resolvedUrl]); const isLoading = isResolving || (shouldResolve && !resolvedUrl) || Boolean(resolvedUrl && !isLoaded); return ( <> {isLoading ? ( ) : null} {resolvedUrl ? ( setIsLoaded(true)} onError={() => setIsLoaded(true)} /> ) : null} > ); } function getLayerImageSequenceFrames(layer: CanvasLayer) { return layer.imageSequenceFrames ?? []; } function getImageSequenceFrameKey( layer: CanvasLayer, frame: ReturnType[number], index: number, ) { return [ layer.id, layer.taskId ?? layer.resourceId ?? '', frame.objectKey ?? '', frame.imageSrc, index, ].join(':'); } function ImageCanvasImageSequenceFrame({ layer, frame, frameKey, isVisible, isLoaded, mediaTransform, onFrameReady, }: { layer: CanvasLayer; frame: ReturnType[number]; frameKey: string; isVisible: boolean; isLoaded: boolean; mediaTransform?: string; onFrameReady: (frameKey: string) => void; }) { const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { objectKey: frame.objectKey, refreshKey: `${layer.taskId ?? layer.resourceId}:${frameKey}`, }); const frameImageRef = useRef(null); useEffect(() => { if (resolvedUrl && isImageAlreadyDecoded(frameImageRef.current)) { onFrameReady(frameKey); } }, [frameKey, onFrameReady, resolvedUrl]); if (!resolvedUrl) { return null; } return ( onFrameReady(frameKey)} onError={() => onFrameReady(frameKey)} /> ); } function ImageCanvasImageSequenceLayer({ layer, mediaTransform, }: { layer: CanvasLayer; mediaTransform?: string; }) { const frames = getLayerImageSequenceFrames(layer); const frameItems = frames.map((frame, index) => ({ frame, index, key: getImageSequenceFrameKey(layer, frame, index), })); const frameSequenceKey = frameItems.map((item) => item.key).join('|'); const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(frames.length > 1); const currentFrameItem = frameItems[Math.min(frameIndex, frameItems.length - 1)]; const currentFrameKey = currentFrameItem?.key ?? ''; const [loadedFrameKeys, setLoadedFrameKeys] = useState>( () => new Set(), ); const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); const visibleFrameLoaded = Boolean( visibleFrameKey && loadedFrameKeys.has(visibleFrameKey), ); const hasFrameSource = frameItems.some((item) => item.frame.imageSrc.trim()); const handleFrameReady = useCallback((readyFrameKey: string) => { setLoadedFrameKeys((currentKeys) => { if (currentKeys.has(readyFrameKey)) { return currentKeys; } const nextKeys = new Set(currentKeys); nextKeys.add(readyFrameKey); return nextKeys; }); }, []); useEffect(() => { setFrameIndex(0); setIsPlaying(frames.length > 1); setLoadedFrameKeys(new Set()); setVisibleFrameKey(firstFrameKey); }, [firstFrameKey, frameSequenceKey, frames.length]); useEffect(() => { if (!currentFrameKey) { setVisibleFrameKey(''); return; } if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { return; } setVisibleFrameKey((currentKey) => currentKey === currentFrameKey ? currentKey : currentFrameKey, ); }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { if (!isPlaying || frames.length <= 1) { return undefined; } const durationMs = typeof layer.imageSequenceDurationMs === 'number' && layer.imageSequenceDurationMs > 0 ? layer.imageSequenceDurationMs : (frames.length / 8) * 1_000; const intervalMs = durationMs / frames.length; const timer = window.setInterval(() => { setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); }, intervalMs); return () => window.clearInterval(timer); }, [frames.length, isPlaying, layer.imageSequenceDurationMs]); return ( <> {hasFrameSource && !visibleFrameLoaded ? ( ) : null} {frameItems.map((item) => ( ))} event.stopPropagation()} onClick={(event) => event.stopPropagation()} > setIsPlaying((currentValue) => !currentValue)} > {isPlaying ? ( ) : ( )} {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${ frames.length || 0 }`} > ); } const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ layer, isSelected, isHovered, layerGeneratingLabel, isKindMenuOpen, inverseScaleStyle, handlers, openLayerKindTriggerRef, openLayerKindMenuRef, }: { layer: CanvasLayer; isSelected: boolean; isHovered: boolean; layerGeneratingLabel: string | null; isKindMenuOpen: boolean; inverseScaleStyle: CSSProperties & Record; handlers: ImageCanvasLayerHandlers; openLayerKindTriggerRef: RefObject; openLayerKindMenuRef: RefObject; }) { const kindTagMeta = getLayerKindTagMeta(layer); const kindLabel = kindTagMeta.label; const mediaTransform = layer.flipX || layer.flipY ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` : undefined; const isMediaContainer = isCanvasMediaContainerLayer(layer); const metadataMediaLabel = getLayerMetadataMediaLabel(layer); return ( handlers.onLayerPointerDown(event, layer)} onClick={(event) => { handlers.setOpenLayerKindMenuId(null); handlers.onLayerClick(event, layer); }} onContextMenu={(event) => { handlers.setOpenLayerKindMenuId(null); handlers.onLayerContextMenu(event, layer); }} onKeyDown={isMediaContainer ? handleLayerKeyboardActivation : undefined} onMouseEnter={() => handlers.onLayerMouseEnter(layer.id)} onMouseLeave={() => handlers.onLayerMouseLeave(layer.id)} aria-label={`选择${layer.title}`} > {layer.mediaType === 'audio' ? ( handlers.onSelectLayer(layer.id)} /> ) : layer.mediaType === 'video' ? ( ) : layer.mediaType === 'image-sequence' ? ( ) : ( )} {kindLabel ? ( event.stopPropagation()} onClick={(event) => { event.stopPropagation(); handlers.setOpenLayerKindMenuId((currentId) => currentId === layer.id ? null : layer.id, ); }} onKeyDown={handleLayerTagKeyboardActivation} > {kindLabel} ) : null} } style={inverseScaleStyle} onClick={(event) => { event.stopPropagation(); handlers.onOpenLayerMetadata(layer); }} onPointerDown={(event) => event.stopPropagation()} /> {isHovered && layer.mediaType !== 'audio' ? ( {getLayerMetricLabel(layer)} ) : null} {layerGeneratingLabel ? ( {layerGeneratingLabel} ) : null} {isKindMenuOpen ? ( event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => { const compatible = isCanvasAssetKindOverrideCompatible( resolveLayerResourceAssetKind(layer), option.value, ); return ( { event.stopPropagation(); handlers.onUpdateLayerAssetKind(layer.id, option.value); handlers.setOpenLayerKindMenuId(null); }} > {option.label} ); })} ) : null} ); }); export function ImageCanvasWorldView({ viewport, snapGuide, layers, selectedLayerIds, hoveredLayerId, canvasMarquee, canvasGenerationDialogs, generateDialog, cropExpandPanel, cropExpandSourceLayer, generationComposerStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, onLayerPointerDown, onLayerClick, onSelectLayer, onLayerContextMenu, onLayerMouseEnter, onLayerMouseLeave, onOpenLayerMetadata, onUpdateLayerAssetKind, onGenerationFramePointerDown, onGenerationFrameContextMenu, onActivateGenerationDialog, onCropExpandHandlePointerDown, }: ImageCanvasWorldViewProps) { const inverseScale = getInverseViewportScale(viewport); const inverseScaleStyle = useMemo( () => buildInverseScaleStyle(inverseScale), [inverseScale], ); const selectedLayerIdSet = useMemo( () => new Set(selectedLayerIds), [selectedLayerIds], ); const [openLayerKindMenuId, setOpenLayerKindMenuId] = useState( null, ); const openLayerKindTriggerRef = useRef(null); const openLayerKindMenuRef = useRef(null); const latestLayerHandlersRef = useRef({ onLayerPointerDown, onLayerClick, onSelectLayer, onLayerContextMenu, onLayerMouseEnter, onLayerMouseLeave, onOpenLayerMetadata, onUpdateLayerAssetKind, setOpenLayerKindMenuId, }); useLayoutEffect(() => { latestLayerHandlersRef.current = { onLayerPointerDown, onLayerClick, onSelectLayer, onLayerContextMenu, onLayerMouseEnter, onLayerMouseLeave, onOpenLayerMetadata, onUpdateLayerAssetKind, setOpenLayerKindMenuId, }; }, [ onLayerPointerDown, onLayerClick, onSelectLayer, onLayerContextMenu, onLayerMouseEnter, onLayerMouseLeave, onOpenLayerMetadata, onUpdateLayerAssetKind, ]); const layerHandlers = useMemo( () => ({ onLayerPointerDown: (event, layer) => latestLayerHandlersRef.current.onLayerPointerDown(event, layer), onLayerClick: (event, layer) => latestLayerHandlersRef.current.onLayerClick(event, layer), onSelectLayer: (layerId) => latestLayerHandlersRef.current.onSelectLayer(layerId), onLayerContextMenu: (event, layer) => latestLayerHandlersRef.current.onLayerContextMenu(event, layer), onLayerMouseEnter: (layerId) => latestLayerHandlersRef.current.onLayerMouseEnter(layerId), onLayerMouseLeave: (layerId) => latestLayerHandlersRef.current.onLayerMouseLeave(layerId), onOpenLayerMetadata: (layer) => latestLayerHandlersRef.current.onOpenLayerMetadata(layer), onUpdateLayerAssetKind: (layerId, assetKind) => latestLayerHandlersRef.current.onUpdateLayerAssetKind( layerId, assetKind, ), setOpenLayerKindMenuId: (value) => latestLayerHandlersRef.current.setOpenLayerKindMenuId(value), }), [], ); const visibleLayers = useMemo( () => layers .filter((layer) => !layer.hidden) .slice() .sort((left, right) => left.zIndex - right.zIndex), [layers], ); useEffect(() => { if (!openLayerKindMenuId) { return undefined; } const closeKindMenuWhenClickingOutside = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) { return; } if ( openLayerKindMenuRef.current?.contains(target) || openLayerKindTriggerRef.current?.contains(target) ) { return; } setOpenLayerKindMenuId(null); }; document.addEventListener('pointerdown', closeKindMenuWhenClickingOutside); return () => { document.removeEventListener( 'pointerdown', closeKindMenuWhenClickingOutside, ); }; }, [openLayerKindMenuId]); return ( {snapGuide?.vertical !== undefined ? ( ) : null} {snapGuide?.horizontal !== undefined ? ( ) : null} {visibleLayers.map((layer) => { const isSelected = selectedLayerIdSet.has(layer.id); const isHovered = hoveredLayerId === layer.id; const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( layer.id, ) ? '拆图中' : generateDialog?.mode === 'edit' && generateDialog.status === 'generating' && generateDialog.sourceLayerId === layer.id ? '修改中' : null; return ( ); })} {cropExpandPanel && cropExpandSourceLayer ? ( {CROP_EXPAND_HANDLES.map((handle) => ( onCropExpandHandlePointerDown(event, handle.value) } /> ))} ) : null} {canvasGenerationDialogs.map((dialog) => { const hasGeneratedLayer = Boolean( dialog.generatedLayerId && layers.some((layer) => layer.id === dialog.generatedLayerId), ); return dialog.placeholder && !hasGeneratedLayer ? (() => { const isFocused = generateDialog?.id === dialog.id; const isSelected = selectedLayerIdSet.has( getCanvasGenerationSelectionId(dialog.id), ); const showFocusedChrome = isFocused || isSelected || dialog.status === 'generating' || dialog.status === 'pending-confirmation'; const placeholderMeta = getGenerationPlaceholderMeta(dialog); return ( onGenerationFramePointerDown(event, dialog) } onContextMenu={(event) => onGenerationFrameContextMenu(event, dialog) } onDoubleClick={() => onActivateGenerationDialog(dialog)} > {showFocusedChrome ? ( {placeholderMeta.labelIcon} {getGenerationFrameLabel(dialog)} ) : null} {placeholderMeta.badgeLabel ? ( {placeholderMeta.badgeLabel} ) : null} {showFocusedChrome && dialog.mode !== 'audio-sound-effect' && dialog.mode !== 'audio-background-music' ? ( {`${dialog.placeholder.originalWidth} x ${dialog.placeholder.originalHeight}`} ) : null} {placeholderMeta.icon} {dialog.status === 'generating' ? ( 生成中 ) : null} {dialog.status === 'pending-confirmation' ? ( 结果待确认 ) : null} ); })() : null; })} {(generateDialog?.mode === 'generate' || generateDialog?.mode === 'scene' || generateDialog?.mode === 'spec' || generateDialog?.mode === 'character' || generateDialog?.mode === 'icon' || generateDialog?.mode === 'ui-design' || generateDialog?.mode === 'quick-edit' || generateDialog?.mode === 'publication' || generateDialog?.mode === 'character-animation' || generateDialog?.mode === 'video' || generateDialog?.mode === 'audio-sound-effect' || generateDialog?.mode === 'audio-background-music') && generateDialog.status === 'generating' && generationComposerStyle ? ( 生成中 ) : null} ); }