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' ? (