Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
T
kdletters d0ad8402de 拆分图片画布浮层定位模型
新增 ImageCanvasOverlayModel 承载生成输入框、图片工具栏、快速编辑和角色动画面板定位规则

新增浮层定位模型单测覆盖锚定、边界限制和生成面板可见模式

更新图片画布前端拆分计划和 TRACKING 记录本阶段验证

精简 ImageCanvasEditorView 中的浮层坐标计算
2026-06-17 12:46:41 +08:00

1134 lines
36 KiB
TypeScript

import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import {
type MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
import {
getCanvasLayersByIds,
resolveContextTargetLayerIds,
} from './ImageCanvasLayerCommandModel';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
createLayerFromAsset,
isGeneratedLayer,
isLayerLinkedToAsset,
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
getLayerKindLabel,
} from './ImageCanvasGenerationModel';
import {
isCanvasGenerationComposerVisible,
resolveCharacterAnimationPanelStyle,
resolveGenerationAnchor,
resolveGenerationComposerStyle,
resolveIconComposerStyle,
resolveQuickEditPanelStyle,
resolveSelectedToolbarStyle,
} from './ImageCanvasOverlayModel';
import type {
AssetPointerDragState,
CanvasContextMenuState,
CanvasLayer,
CanvasTool,
CanvasViewport,
EditorAsset,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge';
import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
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<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
const authUiRef = useRef(authUi);
const hasRequestedLoginRef = useRef(false);
const layerCounterRef = useRef(0);
const layersRef = useRef<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const captureCanvasHistoryRef = useRef<() => void>(() => {});
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
const suppressAssetClickRef = useRef(false);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>(null);
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
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 = useCallback(
(deletedAssets: EditorAsset[]) => {
if (!deletedAssets.length) {
return;
}
setLayers((currentLayers) =>
currentLayers.filter(
(layer) =>
!deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
),
);
setSelectedLayerIds((currentIds) =>
currentIds.filter((layerId) =>
layers.every(
(layer) =>
layer.id !== layerId ||
!deletedAssets.some((asset) =>
isLayerLinkedToAsset(layer, asset),
),
),
),
);
setSelectedLayerId((currentId) => {
if (!currentId) {
return currentId;
}
const currentLayer = layers.find((layer) => layer.id === currentId);
return currentLayer &&
deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset))
? null
: currentId;
});
},
[layers],
);
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,
removeCanvasGenerationDialogById,
activateCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
} = useCanvasGenerationDialogs({
onActivate: handleActivateCanvasGenerationDialog,
});
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
? resolveGenerationAnchor({
dialog: activeCanvasGenerationDialog,
generatedLayer: activeGenerationLayer,
})
: null;
const generationComposerStyle = resolveGenerationComposerStyle({
dialog: activeCanvasGenerationDialog,
anchor: generationAnchor,
viewport,
});
const selectedToolbarStyle = resolveSelectedToolbarStyle({
selectedLayer,
viewport,
canvasSize,
});
const imageContextMenuLayer = imageContextMenu
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
: null;
const getContextTargetLayerIds = useCallback(
(menu: CanvasContextMenuState | null = contextMenu) =>
resolveContextTargetLayerIds(menu, selectedLayerIdsRef.current),
[contextMenu],
);
const contextTargetIds = getContextTargetLayerIds(contextMenu);
const contextTargetLayers = getCanvasLayersByIds(layers, contextTargetIds);
const contextShouldShowLayer = contextTargetLayers.some(
(layer) => layer.hidden,
);
const contextShouldUnlockLayer = contextTargetLayers.some(
(layer) => layer.locked,
);
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: false,
}
: currentDialog,
);
}
}, []);
const projectPersistenceRefs = useMemo(
() => ({
layersRef,
viewportRef,
}),
[],
);
const projectPersistenceSetters = useMemo(
() => ({
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
},
}),
[selectSingleLayer],
);
const { projectId, appendCanvasLayersWithResources } =
useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
viewport,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
});
const {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
} = useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
});
const generationWorkflow = useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
});
const {
quickEditPanel,
setQuickEditPanel,
quickEditSourceLayer,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
setCharacterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPrice,
iconDescriptionValues,
isSpecMenuOpen,
setIsSpecMenuOpen,
isCharacterSpecMenuOpen,
setIsCharacterSpecMenuOpen,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isIconSpecMenuOpen,
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
openIconGenerationDialog,
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickIconSpecFromLayer,
submitIconSpritesheetGeneration,
submitQuickEdit,
submitImageGeneration,
updateSpecFormValue,
updateIconDescription,
addIconDescription,
updateCharacterAnimationDuration,
submitCharacterAnimation,
hideGeneratedLayerPanelAfterBlur,
closeGenerateComposer,
clearDeletedLayerGenerationState,
} = generationWorkflow;
const iconComposerStyle = resolveIconComposerStyle({
dialog: activeCanvasGenerationDialog,
composerStyle: generationComposerStyle,
iconDescriptionCount: iconDescriptionValues.length,
});
const quickEditPanelStyle = resolveQuickEditPanelStyle({
panel: quickEditPanel,
sourceLayer: quickEditSourceLayer,
viewport,
canvasSize,
});
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
panel: characterAnimationPanel,
sourceLayer: characterAnimationSourceLayer,
viewport,
canvasSize,
});
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 {
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,
setActiveSidebarPanel,
appendCanvasLayersWithResources,
selectSingleLayer,
});
const clearCanvasFocus = useCallback(() => {
selectSingleLayer(null);
hideGeneratedLayerPanelAfterBlur();
setImageContextMenu(null);
setContextMenu(null);
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
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,
isPickingIconSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickIconSpecFromLayer,
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,
setIsCharacterSpecMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
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 = (
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();
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
};
useImageCanvasAssetPointerDragBridge({
assetPointerDragRef,
suppressAssetClickRef,
assets,
resolveAssetFolderId,
resolveCanvasPoint,
setAssetPointerDrag,
setUploadDropTarget,
updateAssetMoveDropFolder,
moveAssetToFolder,
addAssetLayer,
});
deleteLayerByIdRef.current = deleteLayerById;
const { handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop } =
useImageCanvasCanvasDropWorkflow({
assets,
assetFolders,
setUploadDropTarget,
updateAssetMoveDropFolder,
getCanvasDropPoint,
addAssetLayer,
addUploadedFiles,
});
const handleCanvasContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const position = resolveContextMenuPosition(
event.clientX,
event.clientY,
'blank',
);
setImageContextMenu(null);
setContextMenu({
kind: 'blank',
...position,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
});
};
const handleLayerContextMenu = (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => {
event.preventDefault();
event.stopPropagation();
if (!selectedLayerIds.includes(layer.id)) {
selectSingleLayer(layer.id);
}
const position = resolveContextMenuPosition(
event.clientX,
event.clientY,
'layer',
);
setContextMenu({
kind: 'layer',
layerId: layer.id,
...position,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
});
setImageContextMenu({
layerId: layer.id,
...position,
});
};
const switchTool = (tool: CanvasTool) => {
clearActiveInteraction();
if (tool === 'upload') {
requestUpload('asset');
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);
};
return (
<section
ref={editorRootRef}
className="image-canvas-editor"
aria-label="图片画布编辑器"
onContextMenu={(event) => event.preventDefault()}
>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
multiple
aria-label="上传图片文件"
hidden
onChange={handleUploadInputChange}
/>
{assetPointerDrag?.active ? (
<div
className="image-canvas-editor__asset-drag-preview"
style={{
left: Number.isFinite(assetPointerDrag.currentClientX)
? assetPointerDrag.currentClientX
: 0,
top: Number.isFinite(assetPointerDrag.currentClientY)
? assetPointerDrag.currentClientY
: 0,
}}
aria-hidden="true"
>
{assets.find((asset) => asset.id === assetPointerDrag.assetId)
?.label ?? '素材'}
</div>
) : null}
<ImageCanvasSidebarView
activeSidebarPanel={activeSidebarPanel}
assetListRef={assetListRef}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
assets={assets}
groupedAssets={groupedAssets}
assetFolders={assetFolders}
layers={layers}
selectedLayerId={selectedLayerId}
selectedLayerIds={selectedLayerIds}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
creatingFolder={creatingFolder}
newFolderName={newFolderName}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
allSelectableAssetsSelected={allSelectableAssetsSelected}
assetMarquee={assetMarquee}
setIsAssetSelectionMode={setIsAssetSelectionMode}
setCreatingFolder={setCreatingFolder}
setNewFolderName={setNewFolderName}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
setImageContextMenu={setImageContextMenu}
setContextMenu={setContextMenu}
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
commitNewAssetFolder={commitNewAssetFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
toggleAllAssetsSelected={toggleAllAssetsSelected}
deleteSelectedAssets={deleteSelectedAssets}
closeAssetSelectionMode={closeAssetSelectionMode}
groupSelectedLayers={groupSelectedLayers}
selectSingleLayer={selectSingleLayer}
resolveContextMenuPosition={resolveContextMenuPosition}
getCanvasPointFromClient={getCanvasPointFromClient}
/>
<div className="image-canvas-editor__main">
<div className="image-canvas-editor__topbar">
<a
className="image-canvas-editor__project-back-button"
href="/project"
aria-label="返回项目页面"
title="返回项目"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
<div className="image-canvas-editor__title-block">
{isRenamingProject ? (
<form
className="image-canvas-editor__project-title-form"
onSubmit={(event) => {
event.preventDefault();
submitProjectRename(projectId);
}}
>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
resetProjectRenameError();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelProjectRename();
}
}}
/>
<EditorIconButton
type="submit"
label="保存项目名称"
title="保存"
icon={Check}
disabled={isProjectRenameSaving}
/>
<EditorIconButton
label="取消修改项目名称"
title="取消"
icon={X}
disabled={isProjectRenameSaving}
onClick={cancelProjectRename}
/>
{projectRenameError ? (
<span
className="image-canvas-editor__project-title-error"
role="alert"
>
{projectRenameError}
</span>
) : null}
</form>
) : (
<div className="image-canvas-editor__project-title-row">
<button
type="button"
className="image-canvas-editor__project-title-button"
onDoubleClick={startProjectRename}
aria-label={`编辑项目名称${projectTitle}`}
>
<h1>{projectTitle}</h1>
</button>
<EditorIconButton
className="image-canvas-editor__project-rename-button"
label="编辑项目名称"
title="编辑项目名称"
icon={Pencil}
onClick={startProjectRename}
/>
</div>
)}
<span>画布</span>
</div>
<div className="image-canvas-editor__topbar-actions">
<EditorIconButton
label="下载画布素材"
title="下载画布素材"
icon={Download}
disabled={
isExportingAssets ||
!layers.some((layer) => layer.src.trim().length > 0)
}
onClick={() => void exportCanvasAssets()}
/>
{assetExportStatus ? (
<PlatformStatusMessage
tone={assetExportStatus.tone}
surface="platform"
size="xs"
role={assetExportStatus.tone === 'error' ? 'alert' : 'status'}
>
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
</div>
</div>
<ImageCanvasStageView
canvasViewportRef={canvasViewportRef}
specToolWrapRef={specToolWrapRef}
isPanning={isPanning}
effectiveTool={effectiveTool}
canvasBackgroundColor={canvasBackgroundColor}
canvasBackgroundHexValue={canvasBackgroundHexValue}
viewport={viewport}
snapGuide={snapGuide}
layers={layers}
selectedLayer={selectedLayer}
selectedLayerIds={selectedLayerIds}
hoveredLayerId={hoveredLayerId}
canvasMarquee={canvasMarquee}
canvasGenerationDialogs={canvasGenerationDialogs}
generateDialog={generateDialog}
quickEditPanel={quickEditPanel}
generationComposerStyle={generationComposerStyle}
selectedToolbarStyle={selectedToolbarStyle}
uploadDropTarget={uploadDropTarget}
contextMenu={contextMenu}
canvasClipboard={canvasClipboard}
imageContextMenu={imageContextMenu}
imageContextMenuLayer={imageContextMenuLayer}
contextShouldShowLayer={contextShouldShowLayer}
contextShouldUnlockLayer={contextShouldUnlockLayer}
canUndo={canUndo}
canRedo={canRedo}
isZoomMenuOpen={isZoomMenuOpen}
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
activeSidebarPanel={activeSidebarPanel}
isMinimapOpen={isMinimapOpen}
minimapModel={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) =>
setHoveredLayerId((currentId) =>
currentId === layerId ? null : currentId,
)
}
onOpenLayerMetadata={(layer) => {
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}
>
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
characterSpecButtonRef={characterSpecButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
isSpecMenuOpen={isSpecMenuOpen}
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
isIconSpecMenuOpen={isIconSpecMenuOpen}
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
quickEditPanel={quickEditPanel}
quickEditSourceLayer={quickEditSourceLayer}
quickEditPanelStyle={quickEditPanelStyle}
quickEditSizeOptions={quickEditSizeOptions}
quickEditModelOptions={quickEditModelOptions}
characterAnimationPanel={characterAnimationPanel}
characterAnimationSourceLayer={characterAnimationSourceLayer}
characterAnimationPanelStyle={characterAnimationPanelStyle}
characterAnimationPrice={characterAnimationPrice}
setGenerateDialog={setGenerateDialog}
setQuickEditPanel={setQuickEditPanel}
setCharacterAnimationPanel={setCharacterAnimationPanel}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
onOpenSpecDialog={openSpecDialog}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) =>
void submitIconSpritesheetGeneration(dialog)
}
onSubmitQuickEdit={() => void submitQuickEdit()}
onSubmitCharacterAnimation={() => void submitCharacterAnimation()}
onCloseGenerateComposer={() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
setActiveTool('select');
}}
onUpdateSpecFormValue={updateSpecFormValue}
onUpdateIconDescription={updateIconDescription}
onAddIconDescription={addIconDescription}
onUpdateCharacterAnimationDuration={
updateCharacterAnimationDuration
}
/>
</ImageCanvasStageView>
</div>
<ImageCanvasMetadataModalView
layer={metadataLayer}
onClose={() => setMetadataLayer(null)}
/>
</section>
);
}
export default ImageCanvasEditorView;