import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createEditorAsset } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import type { AssetPointerDragState, CanvasGenerationDialogState, CanvasContextMenuState, CanvasLayer, CanvasTool, CanvasViewport, ImageContextMenuState, } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetCanvasBridge, useImageCanvasAssetLayerCleanup, } from './useImageCanvasAssetCanvasBridge'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface'; import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageController } from './useImageCanvasStageController'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; import { DEFAULT_IMAGE_CANVAS_VIEWPORT, useImageCanvasViewportControls, } from './useImageCanvasViewportControls'; export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); const assetPointerDragRef = useRef(null); const authUiRef = useRef(authUi); const hasRequestedLoginRef = useRef(false); const layerCounterRef = useRef(0); const layersRef = useRef([]); const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); const suppressAssetClickRef = useRef(false); const [layers, setLayers] = useState([]); const [selectedLayerId, setSelectedLayerId] = useState(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); const [imageContextMenu, setImageContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState( null, ); const [uploadDropTarget, setUploadDropTarget] = useState< 'canvas' | 'assets' | null >(null); const captureViewportHistory = useCallback(() => { captureCanvasHistoryRef.current(); }, []); const { viewport, setViewport, canvasSize, minimapModel, updateScaleFromCenter, fitLayers, resolveCanvasPoint, getCanvasDropPoint, getCanvasPointFromClient, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, } = useImageCanvasViewportControls({ canvasViewportRef, layers, captureCanvasHistory: captureViewportHistory, }); selectedLayerIdRef.current = selectedLayerId; selectedLayerIdsRef.current = selectedLayerIds; layersRef.current = layers; viewportRef.current = viewport; authUiRef.current = authUi; const openEditorLoginModal = useCallback( (postLoginAction?: (() => void) | null) => { authUiRef.current?.openLoginModal(postLoginAction); }, [], ); useEffect(() => { if (!authUi || authUi.user || authUi.canAccessProtectedData) { hasRequestedLoginRef.current = false; return; } if (hasRequestedLoginRef.current) { return; } hasRequestedLoginRef.current = true; authUi.openLoginModal(() => { window.location.reload(); }); }, [authUi]); const { projectTitle, setProjectTitle, projectRenameValue, setProjectRenameValue, isRenamingProject, isProjectRenameSaving, projectRenameError, activeSidebarPanel, setActiveSidebarPanel, activeTool, setActiveTool, isZoomMenuOpen, isBackgroundSettingsOpen, isMinimapOpen, canvasBackgroundColor, canvasBackgroundHexValue, startProjectRename, cancelProjectRename, submitProjectRename, resetProjectRenameError, applyCanvasBackgroundColor, handleCanvasBackgroundHexChange, closeEditorChromePanels, toggleSidebarPanel, toggleZoomMenu, closeZoomMenu, toggleBackgroundSettings, toggleMinimap, } = useImageCanvasEditorChrome({ openEditorLoginModal }); const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ layers, setLayers, setSelectedLayerId, setSelectedLayerIds, }); const { assetFolders, setAssetFolders, assets, setAssets, groupedAssets, allSelectableAssetsSelected, renamingAsset, setRenamingAsset, renamingFolder, setRenamingFolder, creatingFolder, setCreatingFolder, newFolderName, setNewFolderName, activeUploadFolderId, setActiveUploadFolderId, isAssetSelectionMode, setIsAssetSelectionMode, selectedAssetIds, setSelectedAssetIds, assetMarquee, assetPointerDrag, setAssetPointerDrag, assetMoveDropFolderId, pinnedAssetMoveFolderId, resolveAssetFolderId, updateAssetMoveDropFolder, startRenamingAsset, commitAssetRename, toggleAssetFolder, commitNewAssetFolder, deleteUploadedAsset, startRenamingFolder, commitFolderRename, deleteAssetFolder, toggleAssetSelected, toggleAllAssetsSelected, deleteSelectedAssets, moveAssetToFolder, closeAssetSelectionMode, handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, } = useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, onDeleteAssets: removeCanvasLayersLinkedToAssets, }); const handleActivateCanvasGenerationDialog = useCallback(() => { setSelectedLayerId(null); setSelectedLayerIds([]); setImageContextMenu(null); }, []); const { generateDialog, setGenerateDialog, generateDialogRef, inactiveGenerateDialogs, setInactiveGenerateDialogs, inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, activateCanvasGenerationDialog, restoreCanvasGenerationDialogs, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, } = useCanvasGenerationDialogs({ onActivate: handleActivateCanvasGenerationDialog, }); canvasGenerationDialogsRef.current = canvasGenerationDialogs; const canvasHistoryRefs = useMemo( () => ({ layersRef, viewportRef, generateDialogRef, inactiveGenerateDialogsRef, selectedLayerIdRef, selectedLayerIdsRef, }), [], ); const canvasHistorySetters = useMemo( () => ({ setLayers, setViewport, setGenerateDialog, setInactiveGenerateDialogs, setSelectedLayerId, setSelectedLayerIds, }), [], ); const canvasHistoryResetters = useMemo( () => ({ setHoveredLayerId, setMetadataLayer, resetCanvasInteractionState: () => resetCanvasInteractionStateRef.current(), }), [], ); const { canUndo, canRedo, captureCanvasHistory, undoCanvasChange, redoCanvasChange, } = useCanvasHistory({ refs: canvasHistoryRefs, setters: canvasHistorySetters, resetters: canvasHistoryResetters, }); captureCanvasHistoryRef.current = captureCanvasHistory; const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); if (layerId) { setGenerateDialog((currentDialog) => isCanvasGenerationComposerVisible(currentDialog) ? { ...currentDialog, composerOpen: currentDialog.generatedLayerId === layerId, } : currentDialog, ); } }, []); const persistGeneratedAsset = useCallback( (layer: CanvasLayer) => { if (authUiRef.current && !authUiRef.current.canAccessProtectedData) { openEditorLoginModal(); return; } createEditorAsset({ folderId: activeUploadFolderId, label: layer.title, imageSrc: layer.src, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, width: layer.originalWidth, height: layer.originalHeight, sourceType: 'generated', prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, provider: layer.provider, taskId: layer.taskId, }) .then((asset) => { setAssets((currentAssets) => [ ...currentAssets.filter( (currentAsset) => currentAsset.id !== asset.assetId, ), { id: asset.assetId, label: asset.label, src: asset.imageSrc, width: asset.width, height: asset.height, folderId: asset.folderId, sourceKind: 'uploaded', sourceType: asset.sourceType, persisted: true, prompt: asset.prompt ?? undefined, actualPrompt: asset.actualPrompt ?? undefined, model: asset.model ?? undefined, provider: asset.provider ?? undefined, taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, }, ]); setAssetFolders((currentFolders) => currentFolders.map((folder) => folder.id === asset.folderId ? { ...folder, collapsed: false, } : folder, ), ); setLayers((currentLayers) => currentLayers.map((currentLayer) => currentLayer.id === layer.id ? { ...currentLayer, sourceAssetId: asset.assetId, objectKey: asset.objectKey ?? currentLayer.objectKey, assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, } : currentLayer, ), ); }) .catch((error: unknown) => { if ( error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403) ) { openEditorLoginModal(); } }); }, [activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets], ); const projectPersistenceRefs = useMemo( () => ({ layersRef, viewportRef, canvasGenerationDialogsRef, }), [], ); const projectPersistenceSetters = useMemo( () => ({ setProjectTitle, setProjectRenameValue, setViewport, setLayers, selectSingleLayer, setLayerCounter: (value: number) => { layerCounterRef.current = value; }, restoreCanvasGenerationDialogs, }), [restoreCanvasGenerationDialogs, selectSingleLayer], ); const { projectId, appendCanvasLayersWithResources } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, layers, canvasGenerationDialogs, viewport, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, }); const { assetExportStatus, isExportingAssets, exportCanvasAssets, exportLayerImage, } = useImageCanvasAssetExportWorkflow({ layers, projectId, projectTitle, }); const { uploadInputRef, setUploadTarget, requestUpload, handleUploadInputChange, addUploadedFiles, } = useImageCanvasUploadWorkflow({ canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, assetFolders, activeUploadFolderId, canvasSize, viewport, activeTool, allocateUploadIndex: () => { layerCounterRef.current += 1; return layerCounterRef.current; }, setAssetFolders, setAssets, setLayers, setGenerateDialog, appendCanvasLayersWithResources, selectSingleLayer, }); const generationSurface = useImageCanvasGenerationSurface({ layers, canvasSize, viewport, setViewport, layerCounterRef, specToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, iconSpecButtonRef, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, selectSingleLayer, fitLayers, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, requestUpload, persistGeneratedAsset, }); const { quickEditPanel, setQuickEditPanel, setIsSpecMenuOpen, setIsGenerationReferenceMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, isPickingGenerationReferenceFromCanvas, setIsPickingGenerationReferenceFromCanvas, isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas, setIsUiDesignSpecMenuOpen, isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, openCharacterAnimationPanel, openEditDialog, openQuickEditPanel, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, pickUiDesignSpecFromLayer, hideGeneratedLayerPanelAfterBlur, clearDeletedLayerGenerationState, generationComposerStyle, generationComposerNode, switchGenerationTool, } = generationSurface; const { selectedLayer, selectedToolbarStyle, imageContextMenuLayer, contextShouldShowLayer, contextShouldUnlockLayer, clearCanvasFocus, handleCanvasContextMenu, handleLayerContextMenu, } = useImageCanvasStageController({ layers, selectedLayerId, selectedLayerIds, imageContextMenu, setImageContextMenu, contextMenu, setContextMenu, viewport, canvasSize, selectSingleLayer, hideGeneratedLayerPanelAfterBlur, getCanvasPointFromClient, }); const { canvasClipboard, pasteCanvasClipboard, copyContextLayers, duplicateContextLayers, moveContextLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, toggleContextLayerLock, flipContextLayers, deleteContextLayers, exportContextLayer, deleteLayerById, deleteSelectedLayer, groupSelectedLayers, } = useImageCanvasLayerCommands({ layers, contextMenu, selectedLayerId, selectedLayerIds, setLayers, setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, setMetadataLayer, setContextMenu, setImageContextMenu, setActiveTool, captureCanvasHistory, selectSingleLayer, onDeleteLayerSideEffects: clearDeletedLayerGenerationState, exportLayerImage, }); const { canvasMarquee, isPanning, snapGuide, effectiveTool, setIsSpacePanning, setShiftPressed, clearActiveInteraction, handleCanvasPointerDown, handleLayerPointerDown, handleLayerClick, handleGenerationFramePointerDown, handleMinimapPointerDown, handlePointerMove, finishDrag, } = useImageCanvasStageInteractions({ canvasViewportRef, activeTool, layers, setLayers, viewport, setViewport, selectedLayerIds, setSelectedLayerId, setSelectedLayerIds, generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas, isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, isPickingUiDesignSpecFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, pickUiDesignSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, minimapScale: minimapModel?.scale ?? 1, onCloseImageContextMenu: () => setImageContextMenu(null), }); resetCanvasInteractionStateRef.current = clearActiveInteraction; const deleteLayerByIdFromShortcut = useCallback( (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); useImageCanvasKeyboardShortcuts({ generateDialogRef, selectedLayerIdRef, redoCanvasChange, undoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, setActiveTool, setGenerateDialog, setImageContextMenu, setContextMenu, setQuickEditPanel, closeEditorChromePanels, setIsSpecMenuOpen, setIsGenerationReferenceMenuOpen, setIsPickingGenerationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsUiDesignSpecMenuOpen, setIsPickingUiDesignSpecFromCanvas, setIsSpacePanning, setShiftPressed, }); 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 }); }; }, []); const { addAssetLayer, handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop, } = useImageCanvasAssetCanvasBridge({ assetPointerDragRef, suppressAssetClickRef, assets, assetFolders, layerCounterRef, viewport, canvasSize, resolveAssetFolderId, resolveCanvasPoint, getCanvasDropPoint, setAssetPointerDrag, setActiveUploadFolderId, setUploadDropTarget, setHoveredLayerId, updateAssetMoveDropFolder, moveAssetToFolder, captureCanvasHistory, appendCanvasLayersWithResources, selectSingleLayer, addUploadedFiles, }); deleteLayerByIdRef.current = deleteLayerById; const switchTool = (tool: CanvasTool) => { clearActiveInteraction(); if (tool === 'upload') { requestUpload('asset'); return; } if (switchGenerationTool(tool)) { return; } setActiveTool(tool); }; const assetDragPreview = assetPointerDrag?.active ? { x: assetPointerDrag.currentClientX, y: assetPointerDrag.currentClientY, label: assets.find((asset) => asset.id === assetPointerDrag.assetId) ?.label ?? '素材', } : null; const sidebarProps = { activeSidebarPanel, assetListRef, assetPointerDragRef, suppressAssetClickRef, assets, groupedAssets, assetFolders, layers, selectedLayerId, selectedLayerIds, isAssetSelectionMode, selectedAssetIds, assetMoveDropFolderId, pinnedAssetMoveFolderId, creatingFolder, newFolderName, renamingFolder, renamingAsset, allSelectableAssetsSelected, assetMarquee, setIsAssetSelectionMode, setCreatingFolder, setNewFolderName, setRenamingFolder, setRenamingAsset, setActiveUploadFolderId, setUploadDropTarget, setAssetPointerDrag, setSelectedAssetIds, setImageContextMenu, setContextMenu, onAssetMarqueePointerDown: handleAssetMarqueePointerDown, onAssetMarqueePointerMove: handleAssetMarqueePointerMove, onAssetMarqueePointerUp: handleAssetMarqueePointerUp, updateAssetMoveDropFolder, addUploadedFiles, requestUpload, moveAssetToFolder, commitNewAssetFolder, toggleAssetFolder, startRenamingFolder, commitFolderRename, deleteAssetFolder, startRenamingAsset, commitAssetRename, deleteUploadedAsset, toggleAssetSelected, addAssetLayer, toggleAllAssetsSelected, deleteSelectedAssets, closeAssetSelectionMode, groupSelectedLayers, selectSingleLayer, resolveContextMenuPosition, getCanvasPointFromClient, }; const topbarProps = { projectId, projectTitle, projectRenameValue, isRenamingProject, isProjectRenameSaving, projectRenameError, layers, assetExportStatus, isExportingAssets, setProjectRenameValue, startProjectRename, cancelProjectRename, submitProjectRename, resetProjectRenameError, exportCanvasAssets, }; const stageProps = { canvasViewportRef, specToolWrapRef, isPanning, effectiveTool, canvasBackgroundColor, canvasBackgroundHexValue, viewport, snapGuide, layers, selectedLayer, selectedLayerIds, hoveredLayerId, canvasMarquee, canvasGenerationDialogs, generateDialog, quickEditPanel, generationComposerStyle, selectedToolbarStyle, uploadDropTarget, contextMenu, canvasClipboard, imageContextMenu, imageContextMenuLayer, contextShouldShowLayer, contextShouldUnlockLayer, canUndo, canRedo, isZoomMenuOpen, isBackgroundSettingsOpen, activeSidebarPanel, isMinimapOpen, minimapModel, onCanvasPointerDown: handleCanvasPointerDown, onCanvasPointerMove: handlePointerMove, onCanvasPointerUp: finishDrag, onCanvasDragOver: handleCanvasDragOver, onCanvasDragLeave: handleCanvasDragLeave, onCanvasDrop: handleCanvasDrop, onCanvasContextMenu: handleCanvasContextMenu, onLayerPointerDown: handleLayerPointerDown, onLayerClick: handleLayerClick, onLayerContextMenu: handleLayerContextMenu, onLayerMouseEnter: setHoveredLayerId, onLayerMouseLeave: (layerId: string) => setHoveredLayerId((currentId) => currentId === layerId ? null : currentId, ), onOpenLayerMetadata: (layer: CanvasLayer) => { setMetadataLayer(layer); selectSingleLayer(layer.id); }, onGenerationFramePointerDown: handleGenerationFramePointerDown, onActivateGenerationDialog: activateCanvasGenerationDialog, onDeleteSelectedLayer: deleteSelectedLayer, onOpenQuickEditPanel: openQuickEditPanel, onOpenEditDialog: openEditDialog, onOpenCharacterAnimationPanel: openCharacterAnimationPanel, onPasteCanvasClipboard: pasteCanvasClipboard, onCopyContextLayers: copyContextLayers, onDuplicateContextLayers: duplicateContextLayers, onMoveContextLayers: moveContextLayers, onGroupContextLayers: groupContextLayers, onUngroupContextLayers: ungroupContextLayers, onToggleContextLayerVisibility: toggleContextLayerVisibility, onToggleContextLayerLock: toggleContextLayerLock, onFlipContextLayers: flipContextLayers, onExportContextLayer: exportContextLayer, onDeleteContextLayers: deleteContextLayers, onDeleteLayerById: deleteLayerById, onCloseContextMenu: () => setContextMenu(null), onCloseImageContextMenu: () => setImageContextMenu(null), onUpdateScaleFromCenter: updateScaleFromCenter, onFitLayers: fitLayers, onUndoCanvasChange: undoCanvasChange, onRedoCanvasChange: redoCanvasChange, onToggleZoomMenu: toggleZoomMenu, onCloseZoomMenu: closeZoomMenu, onToggleBackgroundSettings: toggleBackgroundSettings, onApplyCanvasBackgroundColor: applyCanvasBackgroundColor, onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange, onToggleSidebarPanel: toggleSidebarPanel, onToggleMinimap: toggleMinimap, onMinimapPointerDown: handleMinimapPointerDown, onSwitchTool: switchTool, children: generationComposerNode, }; return ( setMetadataLayer(null), }} /> ); } export default ImageCanvasEditorView;