import { Braces, Check, ChevronDown, ChevronLeft, ChevronRight, ClipboardList, Copy, Crop, Download, Folder, Hand, ImageIcon, ImagePlus, Info, Layers, Map as MapIcon, MousePointer2, Pencil, Redo2, RotateCcw, Shapes, SlidersHorizontal, Sparkles, Trash2, Type, Undo2, WandSparkles, X, } from 'lucide-react'; import JSZip from 'jszip'; import { type CSSProperties, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent as ReactWheelEvent, } from 'react'; import { createPortal } from 'react-dom'; import { ApiClientError } from '../../services/apiClient'; import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { createEditorAsset, createEditorAssetFolder, createEditorProjectResource, deleteEditorAsset, deleteEditorAssetFolder, editEditorImage, type EditorIconSpritesheetGenerationResult, type EditorIconSpritesheetIconResult, type EditorImageGenerationResult, generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, renameEditorProject, saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSelectField, PlatformTextField, } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; import { useAuthUi } from '../auth/AuthUiContext'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ASSET_DRAG_MIME_TYPE, CANVAS_BACKGROUND_OPTIONS, CANVAS_WORLD_SIZE, DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_SIZE, EDITOR_ASSET_FOLDERS, FIT_VIEW_PADDING, MAX_HISTORY_STEPS, MAX_SCALE, MIN_SCALE, MINIMAP_DRAG_SENSITIVITY, MINIMAP_PADDING, MINIMAP_SIZE, TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, escapeCssIdentifier, formatImageSizeValue, formatPercent, getDraggedAssetId, getLayerBounds, hasDataTransferType, hydrateLayer, isGeneratedLayer, isLayerLinkedToAsset, normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveContextMenuPosition, resolveLayerResolutionSize, resolveSnappedLayerPosition, serializeLayer, } from './ImageCanvasEditorModel'; import { blobToUint8Array, buildLayerExportMetadata, formatExportDate, getImageExtensionFromTypeOrSrc, getLayerExportKey, readLayerImageBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; import { CHARACTER_ANIMATION_ACTION_PROMPTS, CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_MODEL, CHARACTER_ANIMATION_RATIO_OPTIONS, CHARACTER_FRAME_DISPLAY_SIZE, CHARACTER_FRAME_ORIGINAL_SIZE, CHARACTER_SPEC_VIEW_OPTIONS, DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, DEFAULT_SPEC_FORM_VALUES, ICON_COMPOSER_HORIZONTAL_CHROME_REM, ICON_COMPOSER_MIN_WIDTH_REM, ICON_DESCRIPTION_CARD_WIDTH_REM, ICON_DESCRIPTION_LIMIT, ICON_FRAME_DISPLAY_SIZE, ICON_FRAME_ORIGINAL_SIZE, SPEC_FRAME_DISPLAY_SIZE, SPEC_FRAME_ORIGINAL_SIZE, SPEC_GENERATION_COST, SPEC_GENERATION_SIZE, SPEC_TYPE_LABEL, buildCharacterGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, buildImageGenerationInputs, buildQuickEditModelOptions, buildQuickEditSizeOptions, buildSpecGenerationInputs, buildSpecPrompt, calculateCharacterAnimationPrice, createCanvasLayerReference, formatLayerImageType, getGenerationFrameAriaLabel, getGenerationFrameLabel, getLayerKindLabel, isCanvasGenerationDialog, resolveCharacterAnimationSourceImageSrc, resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; import type { AssetMarqueeState, AssetPointerDragState, CanvasAssetExportImage, CanvasAssetExportMetadata, CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, CanvasGenerationInputs, CanvasHistorySnapshot, CanvasLayer, CanvasMarqueeState, CanvasTool, CanvasViewport, CharacterAnimationPanelState, DragState, EditorAsset, EditorAssetFolder, GenerateDialogState, ImageContextMenuState, QuickEditPanelState, SidebarPanel, SnapGuide, SpecFormValues, SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; function triggerPlaceholderAction(label: string) { window.alert(`${label}功能建设中`); } function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', ): CSSProperties { const rect = anchor?.getBoundingClientRect(); if (!rect) { return { position: 'fixed', left: 0, top: 0, right: 'auto', bottom: 'auto', zIndex: 70, }; } return { position: 'fixed', left: Math.round(rect.left), top: placement === 'above' ? Math.round(rect.top) : Math.round(rect.bottom + 8), right: 'auto', bottom: 'auto', zIndex: 70, transform: placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined, }; } function renderEditorPortal(node: ReactNode) { if (typeof document === 'undefined') { return node; } return createPortal(node, document.body); } function isImageFile(file: File) { return file.type.startsWith('image/'); } function isEditableTarget(event: KeyboardEvent) { const target = event.target as HTMLElement | null; if (!target) { return false; } return ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable ); } function getPointerButton(event: ReactPointerEvent) { const nativeEvent = event.nativeEvent as PointerEvent; const nativeButtons = Number(nativeEvent.buttons); if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) { return 1; } const syntheticButtons = Number(event.buttons); if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) { return 1; } const syntheticButton = Number(event.button); if (Number.isFinite(syntheticButton)) { return syntheticButton; } const nativeButton = Number(nativeEvent.button); if (Number.isFinite(nativeButton)) { return nativeButton; } return 0; } function getPointerClient(event: ReactPointerEvent) { const nativeEvent = event.nativeEvent as PointerEvent; return { x: Number.isFinite(event.clientX) ? event.clientX : Number.isFinite(nativeEvent.clientX) ? nativeEvent.clientX : 0, y: Number.isFinite(event.clientY) ? event.clientY : Number.isFinite(nativeEvent.clientY) ? nativeEvent.clientY : 0, }; } function getPointerId(event: ReactPointerEvent) { const nativeId = (event.nativeEvent as PointerEvent).pointerId; if (Number.isFinite(event.pointerId)) { return event.pointerId; } return Number.isFinite(nativeId) ? nativeId : -1; } function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ); } export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); const assetPointerDragRef = useRef(null); const authUiRef = useRef(authUi); const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(0); const generationDialogCounterRef = useRef(0); const saveTimerRef = useRef(null); const undoStackRef = useRef([]); const redoStackRef = useRef([]); const layersRef = useRef([]); const viewportRef = useRef({ x: -260, y: 70, scale: 0.82, }); const projectIdRef = useRef(null); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const pendingProjectResourceLayersRef = useRef< Array<{ layer: CanvasLayer; options: { onCreated?: (resourceId: string) => void }; }> >([]); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); const generateDialogRef = useRef(null); const inactiveGenerateDialogsRef = useRef([]); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); const suppressAssetClickRef = useRef(false); const [projectId, setProjectId] = useState(null); const [projectTitle, setProjectTitle] = useState('未命名画布'); const [projectRenameValue, setProjectRenameValue] = useState('未命名画布'); const [isRenamingProject, setIsRenamingProject] = useState(false); const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false); const [projectRenameError, setProjectRenameError] = useState( null, ); const [isProjectReady, setIsProjectReady] = useState(false); const [assetExportStatus, setAssetExportStatus] = useState<{ tone: 'info' | 'success' | 'error'; message: string; } | null>(null); const [isExportingAssets, setIsExportingAssets] = useState(false); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [viewport, setViewport] = useState({ x: -260, y: 70, scale: 0.82, }); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const [assetFolders, setAssetFolders] = useState(EDITOR_ASSET_FOLDERS); const [assets, setAssets] = useState([]); const [layers, setLayers] = useState([]); const [renamingAsset, setRenamingAsset] = useState<{ assetId: string; value: string; } | null>(null); const [renamingFolder, setRenamingFolder] = useState<{ folderId: string; value: string; } | null>(null); const [creatingFolder, setCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); const [selectedAssetIds, setSelectedAssetIds] = useState>( () => new Set(), ); const [assetMarquee, setAssetMarquee] = useState( null, ); const [assetPointerDrag, setAssetPointerDrag] = useState(null); const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< string | null >(null); const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< string | null >(null); const [canvasMarquee, setCanvasMarquee] = useState( null, ); const [selectedLayerId, setSelectedLayerId] = useState(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [activeTool, setActiveTool] = useState('select'); const [isSpacePanning, setIsSpacePanning] = useState(false); const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] = useState(false); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = useState(null); const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< CanvasGenerationDialogState[] >([]); const [uploadTarget, setUploadTarget] = useState('asset'); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); const [ isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, ] = useState(false); const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(false); const [imageContextMenu, setImageContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [canvasClipboard, setCanvasClipboard] = useState(null); const [historyVersion, setHistoryVersion] = useState(0); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); const [uploadDropTarget, setUploadDropTarget] = useState< 'canvas' | 'assets' | null >(null); selectedLayerIdRef.current = selectedLayerId; selectedLayerIdsRef.current = selectedLayerIds; layersRef.current = layers; viewportRef.current = viewport; generateDialogRef.current = generateDialog; inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; const assetsRef = useRef(assets); const addAssetLayerRef = useRef< (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void >(() => {}); const moveAssetToFolderRef = useRef< (assetId: string, folderId: string) => void >(() => {}); authUiRef.current = authUi; const openEditorLoginModal = useCallback( (postLoginAction?: (() => void) | null) => { authUiRef.current?.openLoginModal(postLoginAction); }, [], ); const applyCanvasBackgroundColor = useCallback((color: string) => { const normalizedColor = normalizeCanvasBackgroundHex(color); if (!normalizedColor) { return false; } setCanvasBackgroundColor(normalizedColor); setCanvasBackgroundHexValue(normalizedColor); return true; }, []); useEffect(() => { assetsRef.current = assets; }, [assets]); const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog) ? generateDialog : null; const canvasGenerationDialogs = useMemo( () => activeCanvasGenerationDialog ? [...inactiveGenerateDialogs, activeCanvasGenerationDialog] : inactiveGenerateDialogs, [activeCanvasGenerationDialog, inactiveGenerateDialogs], ); const selectedLayer = useMemo( () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], ); const selectedLayerCount = selectedLayerIds.length; const hasMultipleSelectedLayers = selectedLayerCount > 1; const activeGenerationLayer = useMemo( () => activeCanvasGenerationDialog?.generatedLayerId ? (layers.find( (layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId, ) ?? null) : null, [activeCanvasGenerationDialog, layers], ); const generationAnchor = activeCanvasGenerationDialog ? (activeGenerationLayer ?? activeCanvasGenerationDialog.placeholder ?? null) : null; const generationComposerStyle = activeCanvasGenerationDialog?.status !== 'generating' && activeCanvasGenerationDialog?.composerOpen !== false && generationAnchor ? { left: viewport.x + (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, top: viewport.y + (generationAnchor.y + generationAnchor.height) * viewport.scale + 10, } : null; const iconDescriptionValues = activeCanvasGenerationDialog?.mode === 'icon' ? (activeCanvasGenerationDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) : DEFAULT_ICON_DESCRIPTIONS; const iconComposerStyle: CSSProperties | null = activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle ? { ...generationComposerStyle, width: `${Math.max( ICON_COMPOSER_MIN_WIDTH_REM, ICON_COMPOSER_HORIZONTAL_CHROME_REM + iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM, ).toFixed(1)}rem`, } : null; const selectedToolbarStyle = selectedLayer ? { left: clamp( viewport.x + selectedLayer.x * viewport.scale + (selectedLayer.width * viewport.scale) / 2, TOOLBAR_HALF_WIDTH, Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH), ), top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), } : null; const characterAnimationSourceLayer = characterAnimationPanel ? (layers.find( (layer) => layer.id === characterAnimationPanel.sourceLayerId, ) ?? null) : null; const quickEditSourceLayer = quickEditPanel ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? null) : null; const quickEditPanelStyle = quickEditPanel && quickEditSourceLayer ? { left: clamp( viewport.x + (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * viewport.scale, 12, Math.max(12, canvasSize.width - 12), ), top: clamp( viewport.y + (quickEditSourceLayer.y + quickEditSourceLayer.height) * viewport.scale + 12, 12, Math.max(12, canvasSize.height - 360), ), } : null; const quickEditSizeOptions = quickEditPanel ? buildQuickEditSizeOptions(quickEditPanel.size) : []; const quickEditModelOptions = quickEditPanel ? buildQuickEditModelOptions(quickEditPanel.model) : []; const characterAnimationPrice = characterAnimationPanel ? calculateCharacterAnimationPrice( characterAnimationPanel.resolution, characterAnimationPanel.durationSeconds, ) : 0; const characterAnimationPanelStyle = characterAnimationPanel && characterAnimationSourceLayer ? { left: clamp( viewport.x + (characterAnimationSourceLayer.x + characterAnimationSourceLayer.width) * viewport.scale + 12, 12, Math.max(12, canvasSize.width - 364), ), top: clamp( viewport.y + characterAnimationSourceLayer.y * viewport.scale, 12, Math.max(12, canvasSize.height - 520), ), } : null; const imageContextMenuLayer = imageContextMenu ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) : null; const getContextTargetLayerIds = useCallback( (menu: CanvasContextMenuState | null = contextMenu) => { if (menu?.kind !== 'layer') { return []; } return selectedLayerIdsRef.current.includes(menu.layerId) ? selectedLayerIdsRef.current : [menu.layerId]; }, [contextMenu], ); const contextTargetIds = getContextTargetLayerIds(contextMenu); const contextTargetLayers = layers.filter((layer) => contextTargetIds.includes(layer.id), ); const contextShouldShowLayer = contextTargetLayers.some( (layer) => layer.hidden, ); const contextShouldUnlockLayer = contextTargetLayers.some( (layer) => layer.locked, ); const canUndo = undoStackRef.current.length > 0; const canRedo = redoStackRef.current.length > 0; void historyVersion; const groupedAssets = useMemo( () => assetFolders.map((folder) => ({ ...folder, assets: assets.filter((asset) => asset.folderId === folder.id), })), [assetFolders, assets], ); const selectableAssets = useMemo( () => assets.filter((asset) => asset.sourceKind === 'uploaded'), [assets], ); const allSelectableAssetsSelected = selectableAssets.length > 0 && selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); const createGenerationDialogId = () => { generationDialogCounterRef.current += 1; return `generation-dialog-${generationDialogCounterRef.current}`; }; const archiveActiveCanvasGenerationDialog = () => { const currentDialog = generateDialogRef.current; if (!isCanvasGenerationDialog(currentDialog)) { return; } setInactiveGenerateDialogs((currentDialogs) => currentDialogs.some((dialog) => dialog.id === currentDialog.id) ? currentDialogs : [ ...currentDialogs, { ...currentDialog, composerOpen: false, }, ], ); }; const openCanvasGenerationDialog = ( dialog: Omit, ) => { archiveActiveCanvasGenerationDialog(); setGenerateDialog({ ...dialog, id: createGenerationDialogId(), }); }; const updateCanvasGenerationDialogById = ( dialogId: string, updater: ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null, ) => { setGenerateDialog((currentDialog) => isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId ? updater(currentDialog) : currentDialog, ); setInactiveGenerateDialogs((currentDialogs) => currentDialogs.flatMap((dialog) => { if (dialog.id !== dialogId) { return [dialog]; } const nextDialog = updater(dialog); return nextDialog ? [nextDialog] : []; }), ); }; const removeCanvasGenerationDialogById = (dialogId: string) => { updateCanvasGenerationDialogById(dialogId, () => null); }; const activateCanvasGenerationDialog = ( targetDialog: CanvasGenerationDialogState, ) => { setInactiveGenerateDialogs((currentDialogs) => { const nextDialogs = currentDialogs.filter( (dialog) => dialog.id !== targetDialog.id, ); const currentDialog = generateDialogRef.current; if ( isCanvasGenerationDialog(currentDialog) && currentDialog.id !== targetDialog.id ) { nextDialogs.push({ ...currentDialog, composerOpen: false, }); } return nextDialogs; }); setGenerateDialog({ ...targetDialog, composerOpen: true, }); setSelectedLayerId(null); setSelectedLayerIds([]); setImageContextMenu(null); }; const removeCanvasGenerationDialogsByLayerId = (targetLayerId: string) => { const keepDialog = (dialog: CanvasGenerationDialogState) => dialog.sourceLayerId !== targetLayerId && dialog.generatedLayerId !== targetLayerId; setGenerateDialog((currentDialog) => isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) ? null : currentDialog, ); setInactiveGenerateDialogs((currentDialogs) => currentDialogs.filter(keepDialog), ); }; const getCanvasHistorySnapshot = useCallback( (): CanvasHistorySnapshot => ({ layers: layersRef.current.map((layer) => ({ ...layer })), viewport: { ...viewportRef.current }, generateDialog: generateDialogRef.current ? { ...generateDialogRef.current, placeholder: generateDialogRef.current.placeholder ? { ...generateDialogRef.current.placeholder } : undefined, } : null, inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( (dialog) => ({ ...dialog, placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, }), ), selectedLayerId: selectedLayerIdRef.current, selectedLayerIds: [...selectedLayerIdsRef.current], }), [], ); const restoreCanvasHistorySnapshot = useCallback( (snapshot: CanvasHistorySnapshot) => { setLayers(snapshot.layers.map((layer) => ({ ...layer }))); setViewport({ ...snapshot.viewport }); setGenerateDialog( snapshot.generateDialog ? { ...snapshot.generateDialog, placeholder: snapshot.generateDialog.placeholder ? { ...snapshot.generateDialog.placeholder } : undefined, } : null, ); setInactiveGenerateDialogs( snapshot.inactiveGenerateDialogs.map((dialog) => ({ ...dialog, placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, })), ); setSelectedLayerId(snapshot.selectedLayerId); setSelectedLayerIds([...snapshot.selectedLayerIds]); setHoveredLayerId(null); setMetadataLayer(null); setCanvasMarquee(null); setSnapGuide(null); setImageContextMenu(null); setContextMenu(null); setIsPanning(false); dragStateRef.current = null; }, [], ); const captureCanvasHistory = useCallback( (options: { clearRedo?: boolean } = {}) => { undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), getCanvasHistorySnapshot(), ]; if (options.clearRedo !== false) { redoStackRef.current = []; } setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot], ); const undoCanvasChange = useCallback(() => { const previousSnapshot = undoStackRef.current.at(-1); if (!previousSnapshot) { return; } undoStackRef.current = undoStackRef.current.slice(0, -1); redoStackRef.current = [ ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), getCanvasHistorySnapshot(), ]; restoreCanvasHistorySnapshot(previousSnapshot); setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); const redoCanvasChange = useCallback(() => { const nextSnapshot = redoStackRef.current.at(-1); if (!nextSnapshot) { return; } redoStackRef.current = redoStackRef.current.slice(0, -1); undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), getCanvasHistorySnapshot(), ]; restoreCanvasHistorySnapshot(nextSnapshot); setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); if (layerId) { setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' || currentDialog?.mode === 'spec' || currentDialog?.mode === 'character' || currentDialog?.mode === 'icon' ? { ...currentDialog, composerOpen: false, } : currentDialog, ); } }, []); const hideGeneratedLayerPanelAfterBlur = useCallback(() => { setGenerateDialog((currentDialog) => (currentDialog?.mode === 'generate' || currentDialog?.mode === 'spec' || currentDialog?.mode === 'character' || currentDialog?.mode === 'icon') && currentDialog.status !== 'generating' ? { ...currentDialog, composerOpen: false, } : currentDialog, ); }, []); const clearCanvasFocus = useCallback(() => { selectSingleLayer(null); hideGeneratedLayerPanelAfterBlur(); setImageContextMenu(null); setContextMenu(null); }, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]); const getGeneratingDialogPlaceholder = useCallback( (dialog: GenerateDialogState) => { const currentDialog = generateDialogRef.current; if (dialog.id) { const latestDialog = [ ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), ...inactiveGenerateDialogsRef.current, ].find((candidateDialog) => candidateDialog.id === dialog.id); if (latestDialog?.status === 'generating') { return latestDialog.placeholder ?? dialog.placeholder; } } if ( currentDialog?.mode === dialog.mode && (!dialog.id || currentDialog.id === dialog.id) && currentDialog.status === 'generating' ) { return currentDialog.placeholder ?? dialog.placeholder; } return dialog.placeholder; }, [], ); const createProjectResourceForLayer = useCallback( ( layer: CanvasLayer, options: { onCreated?: (resourceId: string) => void } = {}, ) => { const readyProjectId = projectIdRef.current; if (!readyProjectId) { pendingProjectResourceLayersRef.current.push({ layer, options }); return; } createEditorProjectResource(readyProjectId, { imageSrc: layer.src, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, width: layer.originalWidth, height: layer.originalHeight, sourceType: layer.sourceType, prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, provider: layer.provider, taskId: layer.taskId, sourceResourceId: layer.sourceResourceId, }) .then((resource) => { options.onCreated?.(resource.resourceId); setLayers((currentLayers) => currentLayers.map((currentLayer) => currentLayer.id === layer.id ? { ...currentLayer, resourceId: resource.resourceId, } : currentLayer, ), ); }) .catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } }); }, [openEditorLoginModal], ); const minimapModel = useMemo(() => { const layerBounds = getLayerBounds(layers); if (!layerBounds) { return null; } const visibleBounds = { minX: (0 - viewport.x) / viewport.scale, minY: (0 - viewport.y) / viewport.scale, maxX: (canvasSize.width - viewport.x) / viewport.scale, maxY: (canvasSize.height - viewport.y) / viewport.scale, }; const bounds = { minX: Math.min(layerBounds.minX, visibleBounds.minX), minY: Math.min(layerBounds.minY, visibleBounds.minY), maxX: Math.max(layerBounds.maxX, visibleBounds.maxX), maxY: Math.max(layerBounds.maxY, visibleBounds.maxY), }; const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); const scale = Math.min( (MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth, (MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight, ); const projectRect = (rect: { minX: number; minY: number; maxX: number; maxY: number; }) => ({ left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale, top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale, width: Math.max(2, (rect.maxX - rect.minX) * scale), height: Math.max(2, (rect.maxY - rect.minY) * scale), }); return { bounds, scale, layers: layers.map((layer) => ({ id: layer.id, title: layer.title, rect: projectRect({ minX: layer.x, minY: layer.y, maxX: layer.x + layer.width, maxY: layer.y + layer.height, }), })), viewport: projectRect(visibleBounds), }; }, [canvasSize.height, canvasSize.width, layers, viewport]); useEffect(() => { let cancelled = false; const projectIdFromQuery = typeof window === 'undefined' ? null : new URLSearchParams(window.location.search) .get('projectid') ?.trim() || null; const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject .then((project) => { if (cancelled) { return; } projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; setProjectTitle(nextProjectTitle); setProjectRenameValue(nextProjectTitle); const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); pendingLayers.forEach(({ layer, options }) => { createProjectResourceForLayer(layer, options); }); setViewport(project.viewport); const resourcesById = new Map( project.resources.map((resource) => [ resource.resourceId, { imageSrc: resource.imageSrc }, ]), ); const hydratedLayers = project.layers .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); if (hydratedLayers.length > 0) { layerCounterRef.current = hydratedLayers.length; setLayers(hydratedLayers); selectSingleLayer(hydratedLayers[0]?.id ?? null); } setIsProjectReady(true); }) .catch((error: unknown) => { if (cancelled) { return; } setIsProjectReady(false); if (isEditorAuthError(error)) { openEditorLoginModal(() => { window.location.reload(); }); } }); return () => { cancelled = true; }; }, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]); useEffect(() => { let cancelled = false; loadEditorAssetLibrary() .then((library) => { if (cancelled) { return; } const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); const defaultFolder = nextLibrary.folders.find( (folder) => folder.systemDefault, ); setActiveUploadFolderId( defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', ); }) .catch((error: unknown) => { if (!cancelled && isEditorAuthError(error)) { openEditorLoginModal(); } }); return () => { cancelled = true; }; }, [openEditorLoginModal]); useEffect(() => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { return undefined; } const updateCanvasSize = () => { setCanvasSize({ width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width, height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height, }); }; updateCanvasSize(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', updateCanvasSize); return () => window.removeEventListener('resize', updateCanvasSize); } const observer = new ResizeObserver(updateCanvasSize); observer.observe(viewportElement); return () => observer.disconnect(); }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( (event.ctrlKey || event.metaKey) && event.code === 'KeyZ' && !isEditableTarget(event) ) { event.preventDefault(); if (event.shiftKey) { redoCanvasChange(); } else { undoCanvasChange(); } return; } if (event.key === 'Shift') { isShiftPressedRef.current = true; } if ( (event.key === 'Backspace' || event.key === 'Delete') && !event.repeat && !isEditableTarget(event) ) { const currentDialog = generateDialogRef.current; const currentSelectedLayerId = selectedLayerIdRef.current; if (currentSelectedLayerId) { event.preventDefault(); deleteLayerByIdRef.current(currentSelectedLayerId); return; } if ( currentDialog?.placeholder && currentDialog.status !== 'generating' && (currentDialog.mode === 'generate' || currentDialog.mode === 'spec' || currentDialog.mode === 'character' || currentDialog.mode === 'icon') ) { event.preventDefault(); setGenerateDialog(null); setActiveTool('select'); setIsCharacterSpecMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsIconSpecMenuOpen(false); setIsPickingIconSpecFromCanvas(false); return; } } if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); setIsBackgroundSettingsOpen(false); setIsSpecMenuOpen(false); setImageContextMenu(null); setContextMenu(null); setQuickEditPanel((currentPanel) => currentPanel?.status === 'generating' ? currentPanel : null, ); setIsCharacterSpecMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsIconSpecMenuOpen(false); setIsPickingIconSpecFromCanvas(false); setGenerateDialog((currentDialog) => { if (!currentDialog || currentDialog.status === 'generating') { return currentDialog; } if ( currentDialog.mode === 'generate' || currentDialog.mode === 'spec' ) { return { ...currentDialog, composerOpen: false, }; } if (currentDialog.mode === 'character') { return currentDialog; } if (currentDialog.mode === 'icon') { return currentDialog; } return null; }); return; } if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { return; } event.preventDefault(); setIsSpacePanning(true); }; const handleKeyUp = (event: KeyboardEvent) => { if (event.key === 'Shift') { isShiftPressedRef.current = false; } if (event.code !== 'Space') { return; } event.preventDefault(); setIsSpacePanning(false); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [redoCanvasChange, undoCanvasChange]); useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { const editorElement = editorRootRef.current; if ( editorElement && event.target instanceof Node && editorElement.contains(event.target) && (event.ctrlKey || event.metaKey) ) { event.preventDefault(); } }; window.addEventListener('wheel', blockBrowserZoom, { capture: true, passive: false, }); return () => { window.removeEventListener('wheel', blockBrowserZoom, { capture: true }); }; }, []); useEffect(() => { const updatePointerDrag = (event: PointerEvent) => { const currentDrag = assetPointerDragRef.current; if (!currentDrag || currentDrag.pointerId !== event.pointerId) { return; } const distance = Math.hypot( event.clientX - currentDrag.startClientX, event.clientY - currentDrag.startClientY, ); const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY); const nextDrag: AssetPointerDragState = { ...currentDrag, currentClientX: event.clientX, currentClientY: event.clientY, active: currentDrag.active || distance > 4, dropFolderId, }; assetPointerDragRef.current = nextDrag; setAssetPointerDrag(nextDrag); setUploadDropTarget( resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null, ); updateAssetMoveDropFolder(dropFolderId); }; const finishPointerDrag = (event: PointerEvent) => { const currentDrag = assetPointerDragRef.current; if (!currentDrag || currentDrag.pointerId !== event.pointerId) { return; } const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY) ?? currentDrag.dropFolderId; const draggedAsset = assetsRef.current.find( (asset) => asset.id === currentDrag.assetId, ); assetPointerDragRef.current = null; setAssetPointerDrag(null); setUploadDropTarget(null); updateAssetMoveDropFolder(null); if (!currentDrag.active || !draggedAsset) { return; } suppressAssetClickRef.current = true; window.setTimeout(() => { suppressAssetClickRef.current = false; }, 0); if (dropFolderId && dropFolderId !== draggedAsset.folderId) { moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); return; } if (canvasPoint) { addAssetLayerRef.current(draggedAsset, canvasPoint); } }; window.addEventListener('pointermove', updatePointerDrag); window.addEventListener('pointerup', finishPointerDrag); window.addEventListener('pointercancel', finishPointerDrag); return () => { window.removeEventListener('pointermove', updatePointerDrag); window.removeEventListener('pointerup', finishPointerDrag); window.removeEventListener('pointercancel', finishPointerDrag); }; }, []); useEffect(() => { if (!projectId || !isProjectReady) { return undefined; } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { saveEditorProjectLayout(projectId, { viewport, layers: layers.map(serializeLayer), }).catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } }); }, 450); return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } }; }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { if (targetLayers.length === 0) { return; } const bounds = getLayerBounds(targetLayers); if (!bounds) { return; } const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); const availableWidth = Math.max( 1, canvasSize.width - FIT_VIEW_PADDING * 2, ); const availableHeight = Math.max( 1, canvasSize.height - FIT_VIEW_PADDING * 2, ); const scale = clamp( Math.min( 1, availableWidth / boundsWidth, availableHeight / boundsHeight, ), MIN_SCALE, MAX_SCALE, ); captureCanvasHistory(); setViewport({ x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, scale, }); }, [captureCanvasHistory, canvasSize.height, canvasSize.width, layers], ); const updateScaleFromCenter = (nextScale: number) => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { captureCanvasHistory(); setViewport((currentViewport) => ({ ...currentViewport, scale: clamp(nextScale, MIN_SCALE, MAX_SCALE), })); return; } const rect = viewportElement.getBoundingClientRect(); const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2; const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; captureCanvasHistory(); setViewport((currentViewport) => { const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); const worldX = (centerX - currentViewport.x) / currentViewport.scale; const worldY = (centerY - currentViewport.y) / currentViewport.scale; return { x: centerX - worldX * scale, y: centerY - worldY * scale, scale, }; }); }; const resolveCanvasPoint = (clientX: number, clientY: number) => { const rect = canvasViewportRef.current?.getBoundingClientRect(); if (!rect) { return null; } if ( clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom ) { return null; } return { x: clientX - rect.left, y: clientY - rect.top, }; }; const getCanvasDropPoint = (event: ReactDragEvent) => resolveCanvasPoint(event.clientX, event.clientY) ?? { x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, }; const getCanvasPointFromClient = (clientX: number, clientY: number) => { const rect = canvasViewportRef.current?.getBoundingClientRect(); const screenX = clientX - (rect?.left ?? 0); const screenY = clientY - (rect?.top ?? 0); return { x: (screenX - viewport.x) / viewport.scale, y: (screenY - viewport.y) / viewport.scale, }; }; const duplicateLayersToPoint = ( sourceLayers: CanvasLayer[], canvasPoint?: { x: number; y: number }, options: { renameCopies?: boolean } = {}, ) => { if (!sourceLayers.length) { return []; } const minX = Math.min(...sourceLayers.map((layer) => layer.x)); const minY = Math.min(...sourceLayers.map((layer) => layer.y)); const maxZIndex = layersRef.current.reduce( (maxZ, layer) => Math.max(maxZ, layer.zIndex), 0, ); const stamp = Date.now(); return sourceLayers.map((layer, index) => ({ ...layer, id: `layer-copy-${stamp}-${index}`, resourceId: `local-resource-copy-${stamp}-${index}`, title: options.renameCopies === false ? layer.title : `${layer.title} 副本`, x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, zIndex: maxZIndex + index + 1, groupId: null, })); }; const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => { if (!canvasClipboard?.layers.length) { return; } const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, { renameCopies: canvasClipboard.mode !== 'cut', }); if (!nextLayers.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); setContextMenu(null); }; const copyContextLayers = (options: { cut?: boolean } = {}) => { const targetIds = getContextTargetLayerIds(); const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); if (!targetLayers.length) { return; } setCanvasClipboard({ layers: targetLayers.map((layer) => ({ ...layer })), mode: options.cut ? 'cut' : 'copy', }); if (options.cut) { captureCanvasHistory(); setLayers((currentLayers) => currentLayers.filter((layer) => !targetIds.includes(layer.id)), ); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); } setContextMenu(null); }; const duplicateContextLayers = () => { const targetIds = getContextTargetLayerIds(); const targetLayers = layers.filter((layer) => targetIds.includes(layer.id)); const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setContextMenu(null); }; const updateContextLayers = ( updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer, ) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => currentLayers.map((layer) => targetIds.includes(layer.id) ? updater(layer, targetIds) : layer, ), ); setContextMenu(null); }; const moveContextLayers = (mode: 'up' | 'down' | 'top' | 'bottom') => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } const maxZIndex = layers.reduce( (maxZ, layer) => Math.max(maxZ, layer.zIndex), 0, ); const minZIndex = layers.reduce( (minZ, layer) => Math.min(minZ, layer.zIndex), 0, ); let offsetIndex = 0; updateContextLayers((layer) => { if (mode === 'up') { return { ...layer, zIndex: layer.zIndex + 1 }; } if (mode === 'down') { return { ...layer, zIndex: layer.zIndex - 1 }; } offsetIndex += 1; if (mode === 'top') { return { ...layer, zIndex: maxZIndex + offsetIndex }; } return { ...layer, zIndex: minZIndex - (targetIds.length - offsetIndex + 1) }; }); }; const groupContextLayers = () => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } const groupId = `layer-group-${Date.now()}`; updateContextLayers((layer) => ({ ...layer, groupId, })); }; const ungroupContextLayers = () => { updateContextLayers((layer) => ({ ...layer, groupId: null, })); }; const toggleContextLayerVisibility = () => { const targetIds = getContextTargetLayerIds(); const shouldHide = layers .filter((layer) => targetIds.includes(layer.id)) .some((layer) => !layer.hidden); updateContextLayers((layer) => ({ ...layer, hidden: shouldHide, })); }; const toggleContextLayerLock = () => { const targetIds = getContextTargetLayerIds(); const shouldLock = layers .filter((layer) => targetIds.includes(layer.id)) .some((layer) => !layer.locked); updateContextLayers((layer) => ({ ...layer, locked: shouldLock, })); }; const flipContextLayers = (axis: 'x' | 'y') => { updateContextLayers((layer) => axis === 'x' ? { ...layer, flipX: !layer.flipX, } : { ...layer, flipY: !layer.flipY, }, ); }; const deleteContextLayers = () => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => currentLayers.filter((layer) => !targetIds.includes(layer.id)), ); selectSingleLayer(null); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); setContextMenu(null); }; const exportContextLayer = () => { const targetIds = getContextTargetLayerIds(); const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); if (!targetLayer) { return; } const link = document.createElement('a'); link.href = targetLayer.src; link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`; document.body.appendChild(link); link.click(); link.remove(); setContextMenu(null); }; const resolveAssetFolderId = (clientX: number, clientY: number) => { const listElement = assetListRef.current; if (!listElement) { return null; } const listRect = listElement.getBoundingClientRect(); if ( clientX < listRect.left || clientX > listRect.right || clientY < listRect.top || clientY > listRect.bottom ) { return null; } const folderElements = [ ...listElement.querySelectorAll('[data-asset-folder-id]'), ]; const matchedFolder = folderElements.find((element) => { const rect = element.getBoundingClientRect(); return ( clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom ); }); return matchedFolder?.dataset.assetFolderId ?? null; }; const updateAssetMoveDropFolder = (folderId: string | null) => { setAssetMoveDropFolderId(folderId); if (!folderId) { setPinnedAssetMoveFolderId(null); return; } const listElement = assetListRef.current; const header = listElement?.querySelector( `[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`, ); const listRect = listElement?.getBoundingClientRect(); const headerRect = header?.getBoundingClientRect(); setPinnedAssetMoveFolderId( listRect && headerRect && (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) ? folderId : null, ); }; const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, ) => { setActiveUploadFolderId(asset.folderId); layerCounterRef.current += 1; const nextLayer = createLayerFromAsset( asset, layerCounterRef.current, viewport, { x: position?.x ?? canvasSize.width / 2, y: position?.y ?? canvasSize.height / 2, }, ); captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); createProjectResourceForLayer(nextLayer); }; addAssetLayerRef.current = addAssetLayer; const exportCanvasAssets = async () => { if (isExportingAssets) { return; } const exportableLayers = layers .filter((layer) => layer.src.trim().length > 0) .sort((left, right) => left.zIndex - right.zIndex); if (!exportableLayers.length) { setAssetExportStatus({ tone: 'info', message: '当前画布没有可导出的素材', }); return; } setIsExportingAssets(true); setAssetExportStatus(null); try { const exportedAt = new Date(); const projectName = sanitizeExportFilePart(projectTitle, '未命名画布'); const rootFolderName = `${projectName}-画布素材`; const zip = new JSZip(); const rootFolder = zip.folder(rootFolderName) ?? zip; const imagesFolder = rootFolder.folder('images') ?? rootFolder; const imageByKey = new Map(); const usedFileNames = new Map(); for (const layer of exportableLayers) { const key = getLayerExportKey(layer); if (imageByKey.has(key)) { continue; } const index = imageByKey.size + 1; const safeTitle = sanitizeExportFilePart(layer.title, '画布素材'); const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`; const duplicateCount = usedFileNames.get(baseFileName) ?? 0; usedFileNames.set(baseFileName, duplicateCount + 1); const indexedFileName = duplicateCount > 0 ? `${baseFileName}-${duplicateCount + 1}` : baseFileName; try { const blob = await readLayerImageBlob(layer); const extension = getImageExtensionFromTypeOrSrc(blob.type, layer.src); const file = `images/${indexedFileName}.${extension}`; imageByKey.set(key, { key, file, layer, blob, }); imagesFolder.file( `${indexedFileName}.${extension}`, await blobToUint8Array(blob), ); } catch (error) { imageByKey.set(key, { key, file: `images/${indexedFileName}.png`, layer, error: error instanceof Error ? error.message : '图片读取失败', }); } } const failedImages = [...imageByKey.values()].filter( (image) => image.error, ); const successfulImages = [...imageByKey.values()].filter( (image) => image.blob, ); if (!successfulImages.length) { setAssetExportStatus({ tone: 'error', message: '素材导出失败', }); return; } const metadata: CanvasAssetExportMetadata = { projectId, projectTitle, exportedAt: exportedAt.toISOString(), layers: exportableLayers.map((layer) => { const image = imageByKey.get(getLayerExportKey(layer)); return buildLayerExportMetadata( layer, image?.blob ? image.file : null, image?.error, ); }), failedImages: failedImages.map((image) => ({ key: image.key, title: image.layer.title, src: image.layer.src, error: image.error ?? '图片读取失败', })), }; const manifest = [ `项目:${projectTitle}`, `导出时间:${metadata.exportedAt}`, `素材数量:${successfulImages.length}`, `图层数量:${exportableLayers.length}`, failedImages.length ? `失败素材数量:${failedImages.length}` : null, ] .filter(Boolean) .join('\n'); rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2)); rootFolder.file('manifest.txt', manifest); const zipBlob = await zip.generateAsync({ type: 'blob' }); if ( typeof URL.createObjectURL !== 'function' || typeof URL.revokeObjectURL !== 'function' ) { setAssetExportStatus({ tone: 'error', message: '当前浏览器不支持素材下载', }); return; } const downloadUrl = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = downloadUrl; link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(downloadUrl); setAssetExportStatus({ tone: failedImages.length ? 'error' : 'success', message: failedImages.length ? '部分素材未能导出' : '画布素材已导出', }); } catch { setAssetExportStatus({ tone: 'error', message: '素材导出失败', }); } finally { setIsExportingAssets(false); } }; const startProjectRename = () => { setProjectRenameValue(projectTitle); setProjectRenameError(null); setIsRenamingProject(true); }; const cancelProjectRename = () => { setProjectRenameValue(projectTitle); setProjectRenameError(null); setIsRenamingProject(false); }; const submitProjectRename = () => { const nextTitle = projectRenameValue.trim(); if (!nextTitle) { setProjectRenameError('项目名称不能为空'); return; } if (!projectId || nextTitle === projectTitle) { setProjectRenameValue(projectTitle); setProjectRenameError(null); setIsRenamingProject(false); return; } setIsProjectRenameSaving(true); setProjectRenameError(null); renameEditorProject(projectId, nextTitle) .then((project) => { const savedTitle = project.title?.trim() || nextTitle; setProjectTitle(savedTitle); setProjectRenameValue(savedTitle); setIsRenamingProject(false); }) .catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); } setProjectRenameError( error instanceof Error ? error.message : '重命名项目失败', ); }) .finally(() => setIsProjectRenameSaving(false)); }; const startRenamingAsset = (asset: EditorAsset) => { setRenamingAsset({ assetId: asset.id, value: asset.label, }); }; const commitAssetRename = (asset: EditorAsset) => { const nextLabel = renamingAsset?.value.trim(); if (!nextLabel) { setRenamingAsset(null); return; } setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === asset.id ? { ...currentAsset, label: nextLabel, } : currentAsset, ), ); if (asset.persisted) { updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {}); } setRenamingAsset(null); }; const toggleAssetFolder = (folderId: string) => { const nextFolder = assetFolders.find((folder) => folder.id === folderId); const nextCollapsed = !(nextFolder?.collapsed ?? false); setAssetFolders((currentFolders) => currentFolders.map((folder) => folder.id === folderId ? { ...folder, collapsed: !folder.collapsed, } : folder, ), ); if (nextFolder?.persisted) { updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch( () => {}, ); } }; const commitNewAssetFolder = async () => { const label = newFolderName.trim(); if (!label) { setCreatingFolder(false); setNewFolderName(''); return; } const folderId = `folder-${Date.now()}`; setAssetFolders((currentFolders) => [ ...currentFolders, { id: folderId, label, collapsed: false, systemDefault: false, persisted: false, }, ]); setActiveUploadFolderId(folderId); setCreatingFolder(false); setNewFolderName(''); try { const folder = await createEditorAssetFolder( label, assetFolders.length + 100, ); setAssetFolders((currentFolders) => currentFolders.map((currentFolder) => currentFolder.id === folderId ? { id: folder.folderId, label: folder.label, collapsed: folder.collapsed, systemDefault: folder.systemDefault, persisted: true, } : currentFolder, ), ); setAssets((currentAssets) => currentAssets.map((asset) => asset.folderId === folderId ? { ...asset, folderId: folder.folderId, } : asset, ), ); setActiveUploadFolderId(folder.folderId); } catch { // 本地临时文件夹仍可继续使用;下次刷新以后端为准。 } }; const deleteUploadedAsset = (asset: EditorAsset) => { if (asset.sourceKind !== 'uploaded') { return; } setAssets((currentAssets) => currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), ); setLayers((currentLayers) => currentLayers.filter((layer) => !isLayerLinkedToAsset(layer, asset)), ); setSelectedLayerIds((currentIds) => currentIds.filter((layerId) => layers.every( (layer) => layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), ), ), ); setSelectedLayerId((currentId) => { if (!currentId) { return currentId; } const currentLayer = layers.find((layer) => layer.id === currentId); return currentLayer && isLayerLinkedToAsset(currentLayer, asset) ? null : currentId; }); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); if (asset.persisted) { deleteEditorAsset(asset.id).catch(() => {}); } }; const startRenamingFolder = (folder: EditorAssetFolder) => { setRenamingFolder({ folderId: folder.id, value: folder.label, }); }; const commitFolderRename = (folder: EditorAssetFolder) => { const nextLabel = renamingFolder?.value.trim(); if (!nextLabel) { setRenamingFolder(null); return; } setAssetFolders((currentFolders) => currentFolders.map((currentFolder) => currentFolder.id === folder.id ? { ...currentFolder, label: nextLabel, } : currentFolder, ), ); if (folder.persisted) { updateEditorAssetFolder(folder.id, { label: nextLabel }).catch(() => {}); } setRenamingFolder(null); }; const deleteAssetFolder = (folder: EditorAssetFolder) => { if (folder.systemDefault) { return; } const defaultFolder = assetFolders.find((currentFolder) => currentFolder.systemDefault) ?? assetFolders[0]; if (!defaultFolder) { return; } setAssetFolders((currentFolders) => currentFolders.filter((currentFolder) => currentFolder.id !== folder.id), ); setAssets((currentAssets) => currentAssets.map((asset) => asset.folderId === folder.id ? { ...asset, folderId: defaultFolder.id, } : asset, ), ); if (folder.persisted) { deleteEditorAssetFolder(folder.id) .then((library) => { const nextLibrary = normalizeAssetLibrary(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); }) .catch(() => {}); } }; const toggleAssetSelected = (assetId: string) => { setSelectedAssetIds((currentIds) => { const nextIds = new Set(currentIds); if (nextIds.has(assetId)) { nextIds.delete(assetId); } else { nextIds.add(assetId); } return nextIds; }); }; const toggleAllAssetsSelected = () => { setSelectedAssetIds( allSelectableAssetsSelected ? new Set() : new Set(selectableAssets.map((asset) => asset.id)), ); }; const deleteSelectedAssets = () => { const ids = [...selectedAssetIds]; const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); setAssets((currentAssets) => currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), ); setLayers((currentLayers) => currentLayers.filter( (layer) => !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), ), ); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { void deleteEditorAsset(assetId); }); }; const moveAssetToFolder = (assetId: string, folderId: string) => { const asset = assets.find((currentAsset) => currentAsset.id === assetId); if (!asset || asset.folderId === folderId) { return; } setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === assetId ? { ...currentAsset, folderId, } : currentAsset, ), ); if (asset.persisted) { updateEditorAsset(asset.id, { folderId }).catch(() => {}); } }; moveAssetToFolderRef.current = moveAssetToFolder; const closeAssetSelectionMode = () => { setIsAssetSelectionMode(false); setSelectedAssetIds(new Set()); setAssetMarquee(null); }; const updateAssetSelectionFromMarquee = (selectionRect: { left: number; right: number; top: number; bottom: number; }) => { const nextSelectedIds = new Set(); assetListRef.current ?.querySelectorAll('[data-asset-id]') .forEach((element) => { const assetId = element.dataset.assetId; if (!assetId) { return; } const asset = assets.find( (currentAsset) => currentAsset.id === assetId, ); if (!asset || asset.sourceKind !== 'uploaded') { return; } const rect = element.getBoundingClientRect(); const intersects = rect.left <= selectionRect.right && rect.right >= selectionRect.left && rect.top <= selectionRect.bottom && rect.bottom >= selectionRect.top; if (intersects) { nextSelectedIds.add(assetId); } }); setSelectedAssetIds(nextSelectedIds); }; const handleAssetMarqueePointerDown = ( event: ReactPointerEvent, ) => { if (!isAssetSelectionMode || event.button !== 0) { return; } const target = event.target as HTMLElement; if (target.closest('button, input, textarea, select, [data-asset-id]')) { return; } event.preventDefault(); assetListRef.current?.setPointerCapture?.(event.pointerId); const rect = assetListRef.current?.getBoundingClientRect(); const startX = event.clientX - (rect?.left ?? 0); const startY = event.clientY - (rect?.top ?? 0); setAssetMarquee({ pointerId: event.pointerId, startX, startY, currentX: startX, currentY: startY, }); setSelectedAssetIds(new Set()); }; const handleAssetMarqueePointerMove = ( event: ReactPointerEvent, ) => { if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { return; } event.preventDefault(); const containerRect = assetListRef.current?.getBoundingClientRect(); const currentX = event.clientX - (containerRect?.left ?? 0); const currentY = event.clientY - (containerRect?.top ?? 0); const startClientX = (containerRect?.left ?? 0) + assetMarquee.startX; const startClientY = (containerRect?.top ?? 0) + assetMarquee.startY; setAssetMarquee((currentMarquee) => currentMarquee ? { ...currentMarquee, currentX, currentY, } : null, ); updateAssetSelectionFromMarquee({ left: Math.min(startClientX, event.clientX), right: Math.max(startClientX, event.clientX), top: Math.min(startClientY, event.clientY), bottom: Math.max(startClientY, event.clientY), }); }; const handleAssetMarqueePointerUp = ( event: ReactPointerEvent, ) => { if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) { return; } event.preventDefault(); if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) { assetListRef.current.releasePointerCapture?.(event.pointerId); } setAssetMarquee(null); }; const readImageFileAsDataUrl = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result); return; } reject(new Error('图片读取失败')); }; reader.onerror = () => reject(reader.error ?? new Error('图片读取失败')); reader.readAsDataURL(file); }); const setCharacterGenerationIdle = (dialog: GenerateDialogState) => ({ ...dialog, status: dialog.status === 'failed' ? 'idle' : dialog.status, errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, }); const addCharacterSpecReferenceFiles = async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => currentDialog?.mode === 'character' ? { ...setCharacterGenerationIdle(currentDialog), characterSpecReference: { id: `upload-character-spec-${Date.now()}`, label: imageFile.name || '角色形象规范', src: imageSrc, }, } : currentDialog, ); }; const addCharacterReferenceFiles = async (files: FileList | File[]) => { const imageFiles = Array.from(files).filter(isImageFile); if (!imageFiles.length) { window.alert('请选择图片文件'); return; } const references = await Promise.all( imageFiles.map(async (file, index) => ({ id: `upload-character-reference-${Date.now()}-${index}`, label: file.name || `参考图${index + 1}`, src: await readImageFileAsDataUrl(file), })), ); setGenerateDialog((currentDialog) => currentDialog?.mode === 'character' ? { ...setCharacterGenerationIdle(currentDialog), characterReferences: [ ...(currentDialog.characterReferences ?? []), ...references, ], } : currentDialog, ); }; const pickCharacterSpecFromLayer = (layer: CanvasLayer) => { setGenerateDialog((currentDialog) => currentDialog?.mode === 'character' ? { ...setCharacterGenerationIdle(currentDialog), characterSpecReference: createCanvasLayerReference(layer), composerOpen: true, } : currentDialog, ); setIsPickingCharacterSpecFromCanvas(false); setIsCharacterSpecMenuOpen(false); setImageContextMenu(null); }; const setIconGenerationIdle = (dialog: GenerateDialogState) => ({ ...dialog, status: dialog.status === 'failed' ? 'idle' : dialog.status, errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, }); const addIconSpecReferenceFiles = async (files: FileList | File[]) => { const imageFile = Array.from(files).find(isImageFile); if (!imageFile) { window.alert('请选择图片文件'); return; } const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => currentDialog?.mode === 'icon' ? { ...setIconGenerationIdle(currentDialog), iconSpecReference: { id: `upload-icon-spec-${Date.now()}`, label: imageFile.name || '图标素材规范', src: imageSrc, }, } : currentDialog, ); }; const pickIconSpecFromLayer = (layer: CanvasLayer) => { if (layer.assetKind !== 'icon-spec') { return; } setGenerateDialog((currentDialog) => currentDialog?.mode === 'icon' ? { ...setIconGenerationIdle(currentDialog), iconSpecReference: createCanvasLayerReference(layer), composerOpen: true, } : currentDialog, ); setIsPickingIconSpecFromCanvas(false); setIsIconSpecMenuOpen(false); setImageContextMenu(null); }; const addUploadedLayer = async ( file: File, options: { folderId?: string; canvasPoint?: { x: number; y: number }; uploadIndex?: number; addToCanvas?: boolean; } = {}, ) => { if (!file.type.startsWith('image/')) { window.alert('请选择图片文件'); return; } const fallbackWidth = 420; const fallbackHeight = 315; const uploadFolderId = assetFolders.some( (folder) => folder.id === (options.folderId ?? activeUploadFolderId), ) ? (options.folderId ?? activeUploadFolderId) : 'project'; const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1; layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex); const uploadedAsset: EditorAsset = { id: `upload-${uploadIndex}`, label: file.name || '上传图片', src: '', width: fallbackWidth, height: fallbackHeight, folderId: uploadFolderId, sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, uploadStatus: 'uploading', uploadProgress: 8, uploadMessage: '准备上传', }; setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => currentFolders.map((folder) => folder.id === uploadFolderId ? { ...folder, collapsed: false, } : folder, ), ); let imageSrc = ''; try { imageSrc = await readImageFileAsDataUrl(file); setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id ? { ...asset, src: imageSrc, uploadProgress: 42, uploadMessage: '读取图片', } : asset, ), ); } catch { setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id ? { ...asset, uploadStatus: 'failed', uploadProgress: 100, uploadMessage: '读取失败', } : asset, ), ); return; } const screenPoint = options.canvasPoint ?? { x: canvasSize.width / 2, y: canvasSize.height / 2, }; const fallbackScreenPoint = { x: canvasSize.width > 0 ? canvasSize.width / 2 : 640, y: canvasSize.height > 0 ? canvasSize.height / 2 : 360, }; const normalizedScreenPoint = { x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x, y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y, }; const safeScale = viewport.scale > 0 ? viewport.scale : 1; const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale; const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale; const nextLayer: CanvasLayer = { id: `layer-upload-${uploadIndex}`, resourceId: `local-resource-upload-${uploadIndex}`, title: file.name || '上传图片', src: imageSrc, x: worldCenterX - fallbackWidth / 2, y: worldCenterY - fallbackHeight / 2, width: fallbackWidth, height: fallbackHeight, originalWidth: fallbackWidth, originalHeight: fallbackHeight, zIndex: uploadIndex + 10, sourceType: 'uploaded', sourceAssetId: `upload-${uploadIndex}`, }; if (options.addToCanvas) { setLayers((currentLayers) => [...currentLayers, nextLayer]); } if (options.addToCanvas) { selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); } setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id ? { ...asset, uploadProgress: 68, uploadMessage: '上传中', } : asset, ), ); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, imageSrc, width: fallbackWidth, height: fallbackHeight, sourceType: 'uploaded', }) .then((asset) => { setAssets((currentAssets) => currentAssets.map((currentAsset) => currentAsset.id === uploadedAsset.id ? { ...currentAsset, id: asset.assetId, folderId: asset.folderId, label: asset.label, src: asset.imageSrc, width: asset.width, height: asset.height, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, persisted: true, uploadStatus: undefined, uploadProgress: undefined, uploadMessage: undefined, } : currentAsset, ), ); if (options.addToCanvas) { setLayers((currentLayers) => currentLayers.map((currentLayer) => currentLayer.id === nextLayer.id ? { ...currentLayer, sourceAssetId: asset.assetId, objectKey: asset.objectKey ?? currentLayer.objectKey, assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, } : currentLayer, ), ); } }) .catch((error: unknown) => { const isAuthError = isEditorAuthError(error); if (isAuthError) { openEditorLoginModal(); } setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id ? { ...asset, uploadStatus: 'failed', uploadProgress: 100, uploadMessage: isAuthError ? '请先登录' : '上传失败', } : asset, ), ); }); if (options.addToCanvas) { createProjectResourceForLayer(nextLayer); } if (imageSrc) { const uploadedImage = new Image(); uploadedImage.onload = () => { const originalWidth = uploadedImage.naturalWidth || fallbackWidth; const originalHeight = uploadedImage.naturalHeight || fallbackHeight; const { width, height } = resolveLayerResolutionSize( originalWidth, originalHeight, { width: fallbackWidth, height: fallbackHeight }, ); if (options.addToCanvas) { setLayers((currentLayers) => currentLayers.map((layer) => layer.id === nextLayer.id ? { ...layer, width, height, originalWidth, originalHeight, x: worldCenterX - width / 2, y: worldCenterY - height / 2, } : layer, ), ); } setAssets((currentAssets) => currentAssets.map((asset) => asset.id === uploadedAsset.id ? { ...asset, width: originalWidth, height: originalHeight, } : asset, ), ); }; uploadedImage.src = imageSrc; } }; const addUploadedFiles = ( files: FileList | File[], options: { folderId?: string; canvasPoint?: { x: number; y: number }; addToCanvas?: boolean; } = {}, ) => { const imageFiles = Array.from(files); const currentAuthUi = authUiRef.current; if (currentAuthUi && !currentAuthUi.canAccessProtectedData) { openEditorLoginModal(() => { addUploadedFiles(imageFiles, options); }); return; } imageFiles.forEach((file, index) => { layerCounterRef.current += 1; const uploadIndex = layerCounterRef.current; void addUploadedLayer(file, { ...options, addToCanvas: options.addToCanvas ?? false, uploadIndex, canvasPoint: options.canvasPoint ? { x: options.canvasPoint.x + index * 28, y: options.canvasPoint.y + index * 28, } : undefined, }); }); }; const deleteLayerById = (targetLayerId: string | null) => { if (!targetLayerId) { return; } setImageContextMenu(null); setContextMenu(null); captureCanvasHistory(); setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => layer.id !== targetLayerId, ); const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; selectSingleLayer(nextSelectedLayer?.id ?? null); return nextLayers; }); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer?.id === targetLayerId ? null : currentLayer, ); setQuickEditPanel((currentPanel) => currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, ); setCharacterAnimationPanel((currentPanel) => currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, ); setGenerateDialog((currentDialog) => currentDialog?.mode === 'edit' && currentDialog.sourceLayerId === targetLayerId ? null : currentDialog, ); removeCanvasGenerationDialogsByLayerId(targetLayerId); }; deleteLayerByIdRef.current = deleteLayerById; const deleteSelectedLayer = () => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; if (targetIds.length <= 1) { deleteLayerById(targetIds[0] ?? null); return; } captureCanvasHistory(); setImageContextMenu(null); setContextMenu(null); setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => !targetIds.includes(layer.id), ); const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; selectSingleLayer(nextSelectedLayer?.id ?? null); return nextLayers; }); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); setQuickEditPanel((currentPanel) => currentPanel && targetIds.includes(currentPanel.sourceLayerId) ? null : currentPanel, ); setCharacterAnimationPanel((currentPanel) => currentPanel && targetIds.includes(currentPanel.sourceLayerId) ? null : currentPanel, ); targetIds.forEach(removeCanvasGenerationDialogsByLayerId); }; const openGenerateDialog = () => { const placeholderWidth = 420; const placeholderHeight = 420; const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; openCanvasGenerationDialog({ mode: 'generate', prompt: '', status: 'idle', composerOpen: true, placeholder: { x: worldCenterX - placeholderWidth / 2, y: worldCenterY - placeholderHeight / 2, width: placeholderWidth, height: placeholderHeight, originalWidth: 2048, originalHeight: 2048, }, }); setActiveTool('generate'); selectSingleLayer(null); setQuickEditPanel(null); }; const openSpecDialog = (specType: SpecGenerationType) => { const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; openCanvasGenerationDialog({ mode: 'spec', prompt: '', status: 'idle', composerOpen: true, specType, specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, placeholder: { x: worldCenterX - SPEC_FRAME_DISPLAY_SIZE.width / 2, y: worldCenterY - SPEC_FRAME_DISPLAY_SIZE.height / 2, width: SPEC_FRAME_DISPLAY_SIZE.width, height: SPEC_FRAME_DISPLAY_SIZE.height, originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width, originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height, }, }); setIsSpecMenuOpen(false); setActiveTool('generate'); selectSingleLayer(null); setQuickEditPanel(null); }; const openCharacterAnimationPanel = (layer: CanvasLayer) => { if (layer.assetKind !== 'character') { return; } setImageContextMenu(null); setQuickEditPanel(null); setCharacterAnimationPanel({ sourceLayerId: layer.id, promptText: '', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, status: 'idle', }); selectSingleLayer(layer.id); }; const openCharacterGenerationDialog = () => { const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; setIsSpecMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); openCanvasGenerationDialog({ mode: 'character', prompt: '', status: 'idle', composerOpen: true, characterSpecReference: null, characterReferences: [], placeholder: { x: worldCenterX - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, y: worldCenterY - CHARACTER_FRAME_DISPLAY_SIZE.height / 2, width: CHARACTER_FRAME_DISPLAY_SIZE.width, height: CHARACTER_FRAME_DISPLAY_SIZE.height, originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width, originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height, }, }); setActiveTool('character'); selectSingleLayer(null); setQuickEditPanel(null); }; const openIconGenerationDialog = () => { const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; setIsSpecMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsPickingIconSpecFromCanvas(false); openCanvasGenerationDialog({ mode: 'icon', prompt: '', status: 'idle', composerOpen: true, iconSpecReference: null, iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], placeholder: { x: worldCenterX - ICON_FRAME_DISPLAY_SIZE.width / 2, y: worldCenterY - ICON_FRAME_DISPLAY_SIZE.height / 2, width: ICON_FRAME_DISPLAY_SIZE.width, height: ICON_FRAME_DISPLAY_SIZE.height, originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, }, }); setActiveTool('icon'); selectSingleLayer(null); setQuickEditPanel(null); setCharacterAnimationPanel(null); }; const openEditDialog = (sourceLayer: CanvasLayer) => { setMetadataLayer(null); setImageContextMenu(null); setQuickEditPanel(null); setGenerateDialog({ mode: 'edit', prompt: sourceLayer.prompt ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` : '', status: 'idle', composerOpen: true, sourceLayerId: sourceLayer.id, }); setActiveTool('generate'); }; const openQuickEditPanel = (sourceLayer: CanvasLayer) => { setImageContextMenu(null); setMetadataLayer(null); setGenerateDialog(null); setCharacterAnimationPanel(null); setQuickEditPanel({ sourceLayerId: sourceLayer.id, prompt: '', size: formatImageSizeValue( sourceLayer.originalWidth, sourceLayer.originalHeight, ), model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL, status: 'idle', }); selectSingleLayer(sourceLayer.id); setActiveTool('generate'); }; const addGeneratedResultLayer = ( generated: EditorImageGenerationResult, options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder']; assetKind?: CanvasLayer['assetKind']; title?: string; dialogId?: string; generationInputs?: CanvasGenerationInputs; } = {}, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const originalWidth = generated.width || 1024; const originalHeight = generated.height || 1024; const { width, height } = resolveLayerResolutionSize( originalWidth, originalHeight, { width: 1024, height: 1024 }, ); const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; const frameX = options.frame && options.frame.width > 0 ? options.frame.x + options.frame.width / 2 - width / 2 : undefined; const frameY = options.frame && options.frame.height > 0 ? options.frame.y + options.frame.height / 2 - height / 2 : undefined; const nextLayer: CanvasLayer = { id: options.sourceLayer ? `layer-edit-${generatedIndex}` : `layer-generated-${generatedIndex}`, resourceId: options.sourceLayer ? `local-resource-edit-${generatedIndex}` : `local-resource-generated-${generatedIndex}`, title: options.sourceLayer ? `${options.sourceLayer.title} 修改结果` : (options.title ?? `生成图片 ${generatedIndex}`), src: generated.imageSrc, x: options.sourceLayer ? options.sourceLayer.x + options.sourceLayer.width + 32 : (frameX ?? worldCenterX - width / 2), y: options.sourceLayer ? options.sourceLayer.y : (frameY ?? worldCenterY - height / 2), width, height, originalWidth, originalHeight, zIndex: generatedIndex + 10, sourceType: generated.sourceType, assetKind: options.assetKind, prompt: generated.prompt, actualPrompt: generated.actualPrompt ?? generated.prompt, model: generated.model, provider: generated.provider, taskId: generated.taskId, objectKey: generated.objectKey, assetObjectId: generated.assetObjectId, sourceResourceId: options.sourceLayer?.resourceId, generationInputs: options.generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (options.sourceLayer) { setGenerateDialog(null); setActiveTool('select'); } else if (options.dialogId) { updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => currentDialog.mode === 'character' || currentDialog.mode === 'icon' ? null : { ...currentDialog, status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, placeholder: undefined, errorMessage: undefined, }, ); } if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } createProjectResourceForLayer(nextLayer); }; const addQuickEditResultLayer = ( generated: EditorImageGenerationResult, sourceLayer: CanvasLayer, generationInputs: CanvasGenerationInputs, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const originalWidth = generated.width || sourceLayer.originalWidth || 1024; const originalHeight = generated.height || sourceLayer.originalHeight || 1024; const { width, height } = resolveLayerResolutionSize( originalWidth, originalHeight, { width: sourceLayer.width, height: sourceLayer.height, }, ); const nextLayer: CanvasLayer = { id: `layer-quick-edit-${generatedIndex}`, resourceId: `local-resource-quick-edit-${generatedIndex}`, title: `${sourceLayer.title} 快速编辑`, src: generated.imageSrc, x: sourceLayer.x + sourceLayer.width + 32, y: sourceLayer.y, width, height, originalWidth, originalHeight, zIndex: generatedIndex + 10, sourceType: generated.sourceType, prompt: generated.prompt, actualPrompt: generated.actualPrompt ?? generated.prompt, model: generated.model, provider: generated.provider, taskId: generated.taskId, objectKey: generated.objectKey, assetObjectId: generated.assetObjectId, sourceResourceId: sourceLayer.resourceId, groupId: sourceLayer.groupId, assetKind: sourceLayer.assetKind, generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); setQuickEditPanel(null); setActiveTool('select'); fitLayers([sourceLayer, nextLayer]); createProjectResourceForLayer(nextLayer); }; const addIconSpritesheetResultLayers = ( generated: EditorIconSpritesheetGenerationResult, iconResults: EditorIconSpritesheetIconResult[], generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, ) => { const startX = frame?.x ?? (canvasSize.width / 2 - viewport.x) / viewport.scale - ICON_FRAME_DISPLAY_SIZE.width / 2; const startY = frame?.y ?? (canvasSize.height / 2 - viewport.y) / viewport.scale - ICON_FRAME_DISPLAY_SIZE.height / 2; const spacing = 24; const maxRowWidth = 560; let cursorX = startX; let cursorY = startY; let rowHeight = 0; const nextLayers: CanvasLayer[] = []; iconResults.forEach((icon) => { const originalWidth = icon.width || 128; const originalHeight = icon.height || 128; const { width, height } = resolveLayerResolutionSize( originalWidth, originalHeight, { width: 128, height: 128 }, ); if (cursorX > startX && cursorX + width - startX > maxRowWidth) { cursorX = startX; cursorY += rowHeight + spacing; rowHeight = 0; } layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; nextLayers.push({ id: `layer-icon-${generatedIndex}`, resourceId: `local-resource-icon-${generatedIndex}`, title: icon.name, src: icon.imageSrc, x: cursorX, y: cursorY, width, height, originalWidth, originalHeight, zIndex: generatedIndex + 10, sourceType: 'generated', prompt: generated.prompt, actualPrompt: generated.actualPrompt ?? generated.prompt, model: generated.model, provider: generated.provider, taskId: generated.taskId, assetKind: 'icon', generationInputs, }); cursorX += width + spacing; rowHeight = Math.max(rowHeight, height); }); if (!nextLayers.length) { return; } setLayers((currentLayers) => [...currentLayers, ...nextLayers]); selectSingleLayer(nextLayers[0]?.id ?? null); setActiveSidebarPanel('layers'); if (dialogId) { removeCanvasGenerationDialogById(dialogId); } setActiveTool('select'); nextLayers.forEach((layer) => createProjectResourceForLayer(layer)); }; const updateIconDescription = (index: number, value: string) => { setGenerateDialog((currentDialog) => currentDialog?.mode === 'icon' ? { ...setIconGenerationIdle(currentDialog), iconDescriptions: ( currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS ).map((description, descriptionIndex) => descriptionIndex === index ? value : description, ), } : currentDialog, ); }; const addIconDescription = () => { setGenerateDialog((currentDialog) => { if (currentDialog?.mode !== 'icon') { return currentDialog; } const descriptions = currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; if (descriptions.length >= ICON_DESCRIPTION_LIMIT) { return currentDialog; } return { ...setIconGenerationIdle(currentDialog), iconDescriptions: [...descriptions, ''], }; }); }; const submitIconSpritesheetGeneration = async ( dialog: GenerateDialogState, ) => { if (dialog.mode !== 'icon') { return; } const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; const setSubmittingIconDialog = ( nextDialog: CanvasGenerationDialogState, ) => { updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); }; const iconDescriptions = ( dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS ) .map((description) => description.trim()) .filter(Boolean); if (!dialog.iconSpecReference) { if (canvasDialog) { setSubmittingIconDialog({ ...canvasDialog, status: 'failed', composerOpen: true, errorMessage: '请选择图标素材规范', }); } return; } if (!iconDescriptions.length) { if (canvasDialog) { setSubmittingIconDialog({ ...canvasDialog, status: 'failed', composerOpen: true, errorMessage: '请填写素材描述', }); } return; } if (!canvasDialog) { return; } setSubmittingIconDialog({ ...canvasDialog, iconDescriptions, status: 'generating', composerOpen: false, errorMessage: undefined, }); try { const generated = await generateEditorIconSpritesheet({ referenceImageSrc: dialog.iconSpecReference.src, iconDescriptions, }); addIconSpritesheetResultLayers( generated, generated.iconImageSrcs, buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), getGeneratingDialogPlaceholder(dialog), canvasDialog.id, ); } catch (error) { setSubmittingIconDialog({ ...canvasDialog, iconDescriptions, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), }); } }; const submitQuickEdit = async () => { if (!quickEditPanel || !quickEditSourceLayer) { return; } const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; setQuickEditPanel({ ...quickEditPanel, prompt: normalizedPrompt, status: 'generating', errorMessage: undefined, }); try { const referenceImageSrc = await resolveEditorImageReferenceDataUrl(quickEditSourceLayer.src); const generated = await generateEditorImage({ prompt: normalizedPrompt, size: quickEditPanel.size, kind: 'quick-edit', model: quickEditPanel.model, referenceImageSrcs: [referenceImageSrc], }); addQuickEditResultLayer( generated, quickEditSourceLayer, buildEditGenerationInputs( '快速编辑提示词', normalizedPrompt, quickEditSourceLayer, ), ); } catch (error) { setQuickEditPanel({ ...quickEditPanel, prompt: normalizedPrompt, status: 'failed', errorMessage: resolveImageGenerationErrorMessage(error), }); } }; const submitImageGeneration = async (dialog: GenerateDialogState) => { const normalizedPrompt = dialog.prompt.trim() || (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; if (canvasDialog) { updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ ...currentDialog, prompt: normalizedPrompt, status: 'generating', composerOpen: false, })); } else { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'generating', composerOpen: dialog.mode === 'edit', }); } try { if (dialog.mode === 'edit') { const sourceLayer = layers.find( (layer) => layer.id === dialog.sourceLayerId, ); if (!sourceLayer) { throw new Error('未找到要修改的图片'); } const referenceImageSrc = await resolveEditorImageReferenceDataUrl( sourceLayer.src, ); const generated = await editEditorImage({ prompt: normalizedPrompt, sourceImageSrc: referenceImageSrc, }); addGeneratedResultLayer(generated, { sourceLayer, generationInputs: buildEditGenerationInputs( '修改要求', normalizedPrompt, sourceLayer, ), }); } else if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; const specPrompt = buildSpecPrompt(specType, specValues); const generated = await generateEditorImage({ prompt: specPrompt, size: SPEC_GENERATION_SIZE, model: DEFAULT_IMAGE_MODEL, kind: 'spec', }); addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), assetKind: specType === 'icon' ? 'icon-spec' : 'spec', title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`, dialogId: canvasDialog?.id, generationInputs: buildSpecGenerationInputs(specType, specValues), }); } else if (dialog.mode === 'character') { const referenceImageSrcs = [ dialog.characterSpecReference?.src, ...(dialog.characterReferences ?? []).map( (reference) => reference.src, ), ].filter((src): src is string => Boolean(src)); const generated = await generateEditorImage({ prompt: normalizedPrompt, kind: 'character', ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }); addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), assetKind: 'character', title: `角色形象 ${layerCounterRef.current + 1}`, dialogId: canvasDialog?.id, generationInputs: buildCharacterGenerationInputs( normalizedPrompt, dialog.characterSpecReference, dialog.characterReferences, ), }); } else { const generated = await generateEditorImage({ prompt: normalizedPrompt, }); addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), dialogId: canvasDialog?.id, generationInputs: buildImageGenerationInputs(normalizedPrompt), }); } } catch (error) { if (canvasDialog) { updateCanvasGenerationDialogById(canvasDialog.id, () => ({ ...canvasDialog, prompt: normalizedPrompt, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), })); } else { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'failed', composerOpen: true, errorMessage: resolveImageGenerationErrorMessage(error), }); } } }; const handleWheel = (event: ReactWheelEvent) => { event.preventDefault(); const viewportElement = canvasViewportRef.current; if (!viewportElement) { return; } if (!event.ctrlKey && !event.metaKey) { setViewport((currentViewport) => ({ ...currentViewport, y: currentViewport.y - event.deltaY, })); return; } const rect = viewportElement.getBoundingClientRect(); const pointerX = event.clientX - rect.left; const pointerY = event.clientY - rect.top; const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1; setViewport((currentViewport) => { const nextScale = clamp( currentViewport.scale * scaleMultiplier, MIN_SCALE, MAX_SCALE, ); const worldX = (pointerX - currentViewport.x) / currentViewport.scale; const worldY = (pointerY - currentViewport.y) / currentViewport.scale; return { x: pointerX - worldX * nextScale, y: pointerY - worldY * nextScale, scale: nextScale, }; }); }; const startPan = (event: ReactPointerEvent) => { event.preventDefault(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); setIsPanning(true); dragStateRef.current = { kind: 'pan', pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, startViewport: viewport, }; }; const handleCanvasPointerDown = ( event: ReactPointerEvent, ) => { const button = getPointerButton(event); if (button !== 0 || effectiveTool === 'hand') { startPan(event); return; } if (button !== 0) { return; } const target = event.target as HTMLElement; if ( effectiveTool === 'select' && (event.target === event.currentTarget || target.classList.contains('image-canvas-editor__world')) ) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); const startX = event.clientX - (rect?.left ?? 0); const startY = event.clientY - (rect?.top ?? 0); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); setCanvasMarquee({ pointerId: event.pointerId, startX, startY, currentX: startX, currentY: startY, }); clearCanvasFocus(); return; } clearCanvasFocus(); }; const handleCanvasDragOver = (event: ReactDragEvent) => { if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { event.preventDefault(); setUploadDropTarget('canvas'); event.dataTransfer.dropEffect = 'copy'; return; } if (hasDataTransferType(event.dataTransfer, 'Files')) { event.preventDefault(); setUploadDropTarget('canvas'); event.dataTransfer.dropEffect = 'copy'; } }; const handleCanvasDragLeave = (event: ReactDragEvent) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setUploadDropTarget((currentTarget) => currentTarget === 'canvas' ? null : currentTarget, ); } }; const handleCanvasDrop = (event: ReactDragEvent) => { const draggedAssetId = getDraggedAssetId(event.dataTransfer); if (draggedAssetId) { const draggedAsset = assets.find((asset) => asset.id === draggedAssetId); if (!draggedAsset) { return; } event.preventDefault(); setUploadDropTarget(null); updateAssetMoveDropFolder(null); addAssetLayer(draggedAsset, getCanvasDropPoint(event)); return; } const files = event.dataTransfer.files; if (!files.length) { return; } event.preventDefault(); setUploadDropTarget(null); updateAssetMoveDropFolder(null); const canvasPoint = getCanvasDropPoint(event); const defaultFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; addUploadedFiles(files, { folderId: defaultFolder?.id, canvasPoint, addToCanvas: true, }); }; const handleLayerPointerDown = ( event: ReactPointerEvent, layer: CanvasLayer, ) => { const button = getPointerButton(event); if (button === 1 || effectiveTool === 'hand') { event.stopPropagation(); startPan(event as unknown as ReactPointerEvent); return; } if (button !== 0) { return; } if ( isPickingCharacterSpecFromCanvas && generateDialog?.mode === 'character' ) { event.preventDefault(); event.stopPropagation(); pickCharacterSpecFromLayer(layer); return; } if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') { event.preventDefault(); event.stopPropagation(); pickIconSpecFromLayer(layer); return; } event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; const nextSelectedIds = isMultiSelectGesture ? selectedLayerIds.includes(layer.id) ? selectedLayerIds.length > 1 ? selectedLayerIds.filter((layerId) => layerId !== layer.id) : [layer.id] : [...selectedLayerIds, layer.id] : [layer.id]; setSelectedLayerId(layer.id); setSelectedLayerIds(nextSelectedIds); setGenerateDialog((currentDialog) => { if ( currentDialog?.mode !== 'generate' && currentDialog?.mode !== 'spec' && currentDialog?.mode !== 'character' && currentDialog?.mode !== 'icon' ) { return currentDialog; } if (currentDialog.generatedLayerId === layer.id) { return { ...currentDialog, composerOpen: true, }; } return { ...currentDialog, composerOpen: false, }; }); const dragLayerIds = nextSelectedIds.includes(layer.id) ? nextSelectedIds : [layer.id]; const startLayers = layers .filter((currentLayer) => dragLayerIds.includes(currentLayer.id)) .map((currentLayer) => ({ id: currentLayer.id, x: currentLayer.x, y: currentLayer.y, })); dragStateRef.current = { kind: 'layer', pointerId: getPointerId(event), layerId: layer.id, layerIds: dragLayerIds, startClientX: pointer.x, startClientY: pointer.y, startLayerX: layer.x, startLayerY: layer.y, startLayers, startScale: viewport.scale, }; }; const handleGenerationFramePointerDown = ( event: ReactPointerEvent, dialog: CanvasGenerationDialogState, ) => { if (!dialog.placeholder) { return; } const button = getPointerButton(event); if (button === 1 || effectiveTool === 'hand') { event.stopPropagation(); startPan(event as unknown as ReactPointerEvent); return; } if (button !== 0) { return; } event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); activateCanvasGenerationDialog(dialog); dragStateRef.current = { kind: 'generation-frame', dialogId: dialog.id, pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, startFrameX: dialog.placeholder.x, startFrameY: dialog.placeholder.y, startScale: viewport.scale, }; }; const moveViewportFromMinimapPointer = (clientX: number, clientY: number) => { if (!minimapModel) { return; } const minimapElement = document.querySelector( '.image-canvas-editor__minimap', ) as HTMLElement | null; const rect = minimapElement?.getBoundingClientRect(); if (!rect) { return; } const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width); const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height); const worldX = minimapModel.bounds.minX + (localX - MINIMAP_PADDING) / minimapModel.scale; const worldY = minimapModel.bounds.minY + (localY - MINIMAP_PADDING) / minimapModel.scale; setViewport((currentViewport) => ({ ...currentViewport, x: canvasSize.width / 2 - worldX * currentViewport.scale, y: canvasSize.height / 2 - worldY * currentViewport.scale, })); }; const moveViewportFromMinimapDrag = ( dragState: Extract, clientX: number, clientY: number, ) => { const deltaWorldX = ((clientX - dragState.startClientX) / dragState.minimapScale) * MINIMAP_DRAG_SENSITIVITY; const deltaWorldY = ((clientY - dragState.startClientY) / dragState.minimapScale) * MINIMAP_DRAG_SENSITIVITY; setViewport({ ...dragState.startViewport, x: dragState.startViewport.x - deltaWorldX * dragState.startViewport.scale, y: dragState.startViewport.y - deltaWorldY * dragState.startViewport.scale, }); }; const handleMinimapPointerDown = ( event: ReactPointerEvent, ) => { event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); dragStateRef.current = { kind: 'minimap', pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, startViewport: { ...viewport }, minimapScale: minimapModel?.scale ?? 1, moved: false, }; }; const handlePointerMove = (event: ReactPointerEvent) => { if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); const currentX = event.clientX - (rect?.left ?? 0); const currentY = event.clientY - (rect?.top ?? 0); setCanvasMarquee((currentMarquee) => currentMarquee ? { ...currentMarquee, currentX, currentY, } : null, ); const left = Math.min(canvasMarquee.startX, currentX); const right = Math.max(canvasMarquee.startX, currentX); const top = Math.min(canvasMarquee.startY, currentY); const bottom = Math.max(canvasMarquee.startY, currentY); const selectedIds = layers .filter((layer) => { const layerLeft = viewport.x + layer.x * viewport.scale; const layerTop = viewport.y + layer.y * viewport.scale; const layerRight = layerLeft + layer.width * viewport.scale; const layerBottom = layerTop + layer.height * viewport.scale; return ( layerLeft <= right && layerRight >= left && layerTop <= bottom && layerBottom >= top ); }) .map((layer) => layer.id); setSelectedLayerIds(selectedIds); setSelectedLayerId(selectedIds[0] ?? null); return; } const dragState = dragStateRef.current; const pointerId = getPointerId(event); if ( !dragState || (dragState.pointerId >= 0 && pointerId >= 0 && dragState.pointerId !== pointerId) ) { return; } if (dragState.kind === 'pan') { const pointer = getPointerClient(event); setViewport({ ...dragState.startViewport, x: dragState.startViewport.x + pointer.x - dragState.startClientX, y: dragState.startViewport.y + pointer.y - dragState.startClientY, }); return; } if (dragState.kind === 'generation-frame') { const pointer = getPointerClient(event); const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) => currentDialog.placeholder ? { ...currentDialog, placeholder: { ...currentDialog.placeholder, x: dragState.startFrameX + deltaX, y: dragState.startFrameY + deltaY, }, } : currentDialog, ); return; } if (dragState.kind === 'minimap') { const pointer = getPointerClient(event); const deltaX = pointer.x - dragState.startClientX; const deltaY = pointer.y - dragState.startClientY; if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) { dragState.moved = true; } if (dragState.moved) { moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y); } return; } const movingLayer = layers.find((layer) => layer.id === dragState.layerId); if (!movingLayer) { return; } const pointer = getPointerClient(event); const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const snapped = resolveSnappedLayerPosition( movingLayer, dragState.startLayerX + deltaX, dragState.startLayerY + deltaY, layers, dragState.startScale, ); setSnapGuide(snapped.guide); setLayers((currentLayers) => currentLayers.map((layer) => dragState.layerIds.includes(layer.id) ? (() => { const startLayer = dragState.startLayers.find( (item) => item.id === layer.id, ); if (!startLayer) { return layer; } if (layer.id === dragState.layerId) { return { ...layer, x: snapped.x, y: snapped.y, }; } return { ...layer, x: startLayer.x + deltaX + (snapped.x - (dragState.startLayerX + deltaX)), y: startLayer.y + deltaY + (snapped.y - (dragState.startLayerY + deltaY)), }; })() : layer, ), ); }; const finishDrag = (event: ReactPointerEvent) => { if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { event.preventDefault(); setCanvasMarquee(null); if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { canvasViewportRef.current.releasePointerCapture?.(event.pointerId); } return; } const dragState = dragStateRef.current; const pointerId = getPointerId(event); if ( dragState && (dragState.pointerId < 0 || pointerId < 0 || dragState.pointerId === pointerId) ) { if (dragState.kind === 'minimap' && !dragState.moved) { const pointer = getPointerClient(event); moveViewportFromMinimapPointer(pointer.x, pointer.y); } dragStateRef.current = null; setIsPanning(false); setSnapGuide(null); if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { canvasViewportRef.current.releasePointerCapture?.(event.pointerId); } } }; const switchTool = (tool: CanvasTool) => { dragStateRef.current = null; setIsPanning(false); setSnapGuide(null); if (tool === 'upload') { setUploadTarget('asset'); uploadInputRef.current?.click(); return; } if (tool === 'generate') { openGenerateDialog(); return; } if (tool === 'spec') { setIsSpecMenuOpen((open) => !open); setActiveTool('spec'); return; } if (tool === 'character') { openCharacterGenerationDialog(); return; } if (tool === 'icon') { openIconGenerationDialog(); return; } setActiveTool(tool); }; const toggleSidebarPanel = (panel: SidebarPanel) => { setActiveSidebarPanel((currentPanel) => currentPanel === panel ? null : panel, ); }; const groupSelectedLayers = () => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; if (!targetIds.length) { return; } const groupId = `layer-group-${Date.now()}`; captureCanvasHistory(); setLayers((currentLayers) => currentLayers.map((layer) => targetIds.includes(layer.id) ? { ...layer, groupId, } : layer, ), ); }; const toolButtons = [ { label: '裁剪', icon: Crop }, { label: '重绘', icon: Sparkles }, { label: '调整', icon: SlidersHorizontal }, { label: '复制', icon: Copy }, ]; const canvasTools: Array<{ id: CanvasTool; label: string; icon: typeof MousePointer2; }> = [ { id: 'select', label: '选择工具', icon: MousePointer2 }, { id: 'hand', label: '抓手工具', icon: Hand }, { id: 'upload', label: '上传工具', icon: ImagePlus }, { id: 'generate', label: '生成工具', icon: WandSparkles }, { id: 'spec', label: '生成规范', icon: ClipboardList }, { id: 'character', label: '生成角色形象', icon: Sparkles }, { id: 'icon', label: '生成图标素材', icon: ImageIcon }, { id: 'text', label: '文字工具', icon: Type }, { id: 'shape', label: '形状标注工具', icon: Shapes }, { id: 'export', label: '导出工具', icon: Download }, ]; const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => { setGenerateDialog((currentDialog) => { if (currentDialog?.mode !== 'spec') { return currentDialog; } const specType = currentDialog.specType ?? 'custom'; return { ...currentDialog, specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType], ...currentDialog.specValues, [key]: value, }, status: currentDialog.status === 'failed' ? 'idle' : currentDialog.status, errorMessage: currentDialog.status === 'failed' ? undefined : currentDialog.errorMessage, }; }); }; const updateCharacterAnimationDuration = (frameCountValue: string) => { const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find( (item) => String(item.frameCount) === frameCountValue, ); if (!option) { return; } setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, frameCount: option.frameCount, durationSeconds: option.durationSeconds, status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ); }; const submitCharacterAnimation = async () => { if (!characterAnimationPanel || !characterAnimationSourceLayer) { return; } const promptText = characterAnimationPanel.promptText.trim(); const nextPanel = { ...characterAnimationPanel, promptText, status: 'generating' as const, errorMessage: undefined, result: undefined, }; setCharacterAnimationPanel(nextPanel); try { const result = await generateEditorCharacterAnimation({ sourceLayerId: characterAnimationSourceLayer.id, sourceImageSrc: resolveCharacterAnimationSourceImageSrc( characterAnimationSourceLayer, ), sourceWidth: characterAnimationSourceLayer.originalWidth, sourceHeight: characterAnimationSourceLayer.originalHeight, promptText, resolution: nextPanel.resolution, ratio: nextPanel.ratio, frameCount: nextPanel.frameCount, durationSeconds: nextPanel.durationSeconds, priceMudPoints: calculateCharacterAnimationPrice( nextPanel.resolution, nextPanel.durationSeconds, ), model: CHARACTER_ANIMATION_MODEL, }); setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, status: 'completed', result, } : currentPanel, ); } catch (error) { setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, status: 'failed', errorMessage: error instanceof Error && error.message.trim() ? error.message : '生成角色动画失败', } : currentPanel, ); } }; return (
event.preventDefault()} > { const files = event.currentTarget.files; if (files?.length) { if (uploadTarget === 'character-spec') { void addCharacterSpecReferenceFiles(files); } else if (uploadTarget === 'character-reference') { void addCharacterReferenceFiles(files); } else if (uploadTarget === 'icon-spec') { void addIconSpecReferenceFiles(files); } else { addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); } } setUploadTarget('asset'); event.currentTarget.value = ''; }} /> {assetPointerDrag?.active ? ( ) : null}
{isRenamingProject ? (
{ event.preventDefault(); submitProjectRename(); }} > { setProjectRenameValue(event.target.value); setProjectRenameError(null); }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelProjectRename(); } }} /> {projectRenameError ? ( {projectRenameError} ) : null} ) : (
)} 画布
layer.src.trim().length > 0) } onClick={() => void exportCanvasAssets()} /> {assetExportStatus ? ( {assetExportStatus.message} ) : null}
{ event.preventDefault(); event.stopPropagation(); const position = resolveContextMenuPosition( event.clientX, event.clientY, 'blank', ); setImageContextMenu(null); setContextMenu({ kind: 'blank', ...position, canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY), }); }} > {uploadDropTarget === 'canvas' ? (
添加到画布 松开即可添加
) : null}
{snapGuide?.vertical !== undefined ? (
) : null} {snapGuide?.horizontal !== undefined ? (
) : null} {layers .slice() .filter((layer) => !layer.hidden) .sort((left, right) => left.zIndex - right.zIndex) .map((layer) => { const isSelected = selectedLayerIds.includes(layer.id); const isHovered = hoveredLayerId === layer.id; const kindLabel = getLayerKindLabel(layer); const layerGeneratingLabel = generateDialog?.mode === 'edit' && generateDialog.status === 'generating' && generateDialog.sourceLayerId === layer.id ? '修改中' : quickEditPanel?.status === 'generating' && quickEditPanel.sourceLayerId === layer.id ? '生成中' : null; return ( ); })} {canvasMarquee ? ( {selectedLayer && selectedToolbarStyle ? (
event.stopPropagation()} > {toolButtons.map(({ label, icon: Icon }) => ( triggerPlaceholderAction(label)} /> ))} openQuickEditPanel(selectedLayer)} /> {isGeneratedLayer(selectedLayer) ? ( <> setMetadataLayer(selectedLayer)} /> openEditDialog(selectedLayer)} /> ) : null} {selectedLayer.assetKind === 'character' ? ( openCharacterAnimationPanel(selectedLayer)} /> ) : null}
) : null} {contextMenu ? (
event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} > {contextMenu.kind === 'blank' ? ( <> ) : ( <>



{imageContextMenuLayer ? ( <> {imageContextMenuLayer.assetKind === 'character' ? ( ) : null}
) : null} )}
) : null} fitLayers()} />
event.stopPropagation()} >
setIsZoomMenuOpen((open) => !open)} > {formatPercent(viewport.scale)} {isZoomMenuOpen ? ( { updateScaleFromCenter(viewport.scale * 1.16); setIsZoomMenuOpen(false); }} > 放大 { updateScaleFromCenter(viewport.scale * 0.86); setIsZoomMenuOpen(false); }} > 缩小 { fitLayers(); setIsZoomMenuOpen(false); }} > 显示画布所有元素 {[0.5, 1, 2].map((scale) => ( { updateScaleFromCenter(scale); setIsZoomMenuOpen(false); }} > 缩放至{Math.round(scale * 100)}% ))} ) : null}
setIsBackgroundSettingsOpen((isOpen) => !isOpen) } icon={ } /> {isBackgroundSettingsOpen ? (
画布背景
{CANVAS_BACKGROUND_OPTIONS.map((option) => ( ))}
applyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR) } >
) : null}
toggleSidebarPanel('assets')} /> toggleSidebarPanel('layers')} /> setIsMinimapOpen((open) => !open)} />
{isMinimapOpen && minimapModel ? ( ) : null}
event.stopPropagation()} > {canvasTools.map(({ id, label, icon: Icon }) => id === 'spec' ? ( switchTool(id)} /> ) : ( switchTool(id)} /> ), )}
{isSpecMenuOpen ? renderEditorPortal( {(['character', 'ui', 'custom'] as const).map((specType) => ( openSpecDialog(specType)} > {SPEC_TYPE_LABEL[specType]} ))} , ) : null} {generateDialog?.mode === 'generate' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} > { setUploadTarget('asset'); uploadInputRef.current?.click(); }} icon={} > 参考图 setGenerateDialog((currentDialog) => currentDialog ? { ...currentDialog, prompt: event.target.value, status: currentDialog.status === 'failed' ? 'idle' : currentDialog.status, errorMessage: currentDialog.status === 'failed' ? undefined : currentDialog.errorMessage, } : currentDialog, ) } />
triggerPlaceholderAction('生成参数')} trailingIcon={} > 中 · 1:1(2k) · 1张 triggerPlaceholderAction('模型选择')} trailingIcon={} > GPT Im... {generateDialog.status === 'generating' ? '生成中' : '12'}
{generateDialog.status === 'generating' ? ( 生成中 ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} { setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' ? { ...currentDialog, composerOpen: false, } : currentDialog, ); setActiveTool('select'); }} /> ) : null} {generateDialog?.mode === 'spec' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
{generateDialog.specType === 'custom' ? ( ) : ( <> {generateDialog.specType === 'character' ? ( <> ) : null} )}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
{generateDialog.status === 'generating' ? '生成中' : `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
) : null} {generateDialog?.mode === 'character' && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
角色形象规范
{isCharacterSpecMenuOpen ? renderEditorPortal( { setIsPickingCharacterSpecFromCanvas(true); setIsCharacterSpecMenuOpen(false); }} > 从画布中选择 { setIsCharacterSpecMenuOpen(false); openSpecDialog('character'); }} > 新建角色形象规范 { setUploadTarget('character-spec'); setIsCharacterSpecMenuOpen(false); uploadInputRef.current?.click(); }} > 上传图片 , ) : null}
常规参考图
{(generateDialog.characterReferences ?? []).map( (reference, index) => ( {reference.label} {index + 1} ), )}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
画面比例 triggerPlaceholderAction('角色比例')} > 1:1
模型 triggerPlaceholderAction('角色模型')} > GPT Image
{generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {generateDialog?.mode === 'icon' && generateDialog.composerOpen !== false && iconComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitIconSpritesheetGeneration(generateDialog); } }} >
图标素材规范
{isIconSpecMenuOpen ? renderEditorPortal( { setIsPickingIconSpecFromCanvas(true); setIsIconSpecMenuOpen(false); }} > 从画布中选择 { setIsIconSpecMenuOpen(false); openSpecDialog('icon'); }} > 新建图标素材规范 { setUploadTarget('icon-spec'); setIsIconSpecMenuOpen(false); uploadInputRef.current?.click(); }} > 上传图片 , ) : null}
素材描述
{iconDescriptionValues.map((description, index) => ( ))}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
模型 triggerPlaceholderAction('图标模型')} > nanobanana2
{generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {isPickingCharacterSpecFromCanvas ? (
请选择画布中的图片作为角色形象规范,按 Esc 退出
) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标素材规范,按 Esc 退出
) : null} {quickEditPanel && quickEditPanel.status !== 'generating' && quickEditSourceLayer && quickEditPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); void submitQuickEdit(); }} >
{`${quickEditSourceLayer.title}参考图`} {quickEditSourceLayer.title}
setQuickEditPanel(null)} />
setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, prompt: event.target.value, status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ) } />
setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, size: event.target.value } : currentPanel, ) } > {quickEditSizeOptions.map((size) => ( ))} setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, model: event.target.value } : currentPanel, ) } > {quickEditModelOptions.map((option) => ( ))}
{quickEditPanel.status === 'failed' ? ( {quickEditPanel.errorMessage} ) : null} 生成 ) : null} {imageContextMenu && imageContextMenuLayer && !contextMenu ? (
event.stopPropagation()} > openQuickEditPanel(imageContextMenuLayer)} > 快速编辑 { setMetadataLayer(imageContextMenuLayer); setImageContextMenu(null); }} > 查看图片信息 {imageContextMenuLayer.assetKind === 'character' ? ( openCharacterAnimationPanel(imageContextMenuLayer) } > 生成动画 ) : null} deleteLayerById(imageContextMenuLayer.id)} > 删除图片
) : null} {characterAnimationPanel && characterAnimationSourceLayer && characterAnimationPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (characterAnimationPanel.status !== 'generating') { void submitCharacterAnimation(); } }} >
角色动画 setCharacterAnimationPanel(null)} />
setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, promptText: event.target.value.slice(0, 4000), status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ) } />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( ))}
setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, resolution: event.target.value === '720p' ? '720p' : '480p', status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ) } > setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...currentPanel, ratio: CHARACTER_ANIMATION_RATIO_OPTIONS.find( (item) => item.value === event.target.value, )?.value ?? 'same', status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ) } > {CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( ))} updateCharacterAnimationDuration(event.target.value) } > {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( ))}
{characterAnimationPanel.promptText.trim() ? characterAnimationPanel.promptText.trim() : '动画描述'} {characterAnimationPrice}泥点
{characterAnimationPanel.status === 'completed' && characterAnimationPanel.result ? ( 已生成 {characterAnimationPanel.result.frameCount} 帧 ) : null} {characterAnimationPanel.status === 'failed' ? ( {characterAnimationPanel.errorMessage} ) : null} {characterAnimationPanel.status === 'generating' ? '生成中' : '生成'} ) : null}
setMetadataLayer(null)} panelClassName="image-canvas-editor__metadata-dialog" bodyClassName="image-canvas-editor__metadata-body" > {metadataLayer ? (
图片类型
{formatLayerImageType(metadataLayer)}
生成输入
{metadataLayer.generationInputs?.fields.length || metadataLayer.generationInputs?.references.length ? ( <> {metadataLayer.generationInputs.fields.map((field) => (
{field.title} {field.value}
))} {metadataLayer.generationInputs.references.length ? (
{metadataLayer.generationInputs.references.map( (reference) => (
{reference.title} {reference.label}
), )}
) : null} ) : ( '-' )}
Model
{metadataLayer.model ?? '-'}
Resolution
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
Provider
{metadataLayer.provider ?? '-'}
Task
{metadataLayer.taskId ?? '-'}
Object
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
) : null}
setGenerateDialog(null)} panelClassName="image-canvas-editor__generate-dialog" bodyClassName="image-canvas-editor__generate-dialog-body" > {generateDialog?.mode === 'edit' ? (
{ event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
setGenerateDialog((currentDialog) => currentDialog ? { ...currentDialog, prompt: event.target.value, } : currentDialog, ) } /> {generateDialog.status === 'generating' ? ( {generateDialog.mode === 'edit' ? '修改中' : '生成中'} ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} {generateDialog.status === 'generating' ? generateDialog.mode === 'edit' ? '修改中' : '生成中' : generateDialog.mode === 'edit' ? '修改' : '生成'}
) : null}
); } export default ImageCanvasEditorView;