Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
kdletters 31da3b2fa2 拆分图片画布舞台交互
新增画布舞台交互 hook,承接选择、框选、拖拽、平移和小地图 pointer 状态机

更新历史恢复清理入口,撤销重做时统一重置舞台交互状态

补充舞台交互 hook 测试并更新前端拆分文档和 TRACKING 记录
2026-06-17 10:04:32 +08:00

1453 lines
47 KiB
TypeScript

import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import {
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import {
getCanvasLayersByIds,
resolveContextTargetLayerIds,
} from './ImageCanvasLayerCommandModel';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
ASSET_DRAG_MIME_TYPE,
TOOLBAR_HALF_WIDTH,
clamp,
createLayerFromAsset,
getDraggedAssetId,
hasDataTransferType,
isGeneratedLayer,
isLayerLinkedToAsset,
resolveContextMenuPosition,
serializeLayer,
} from './ImageCanvasEditorModel';
import {
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
ICON_COMPOSER_MIN_WIDTH_REM,
ICON_DESCRIPTION_CARD_WIDTH_REM,
formatLayerImageType,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
getLayerKindLabel,
} from './ImageCanvasGenerationModel';
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 { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
} from './useImageCanvasViewportControls';
function isEditableTarget(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (!target) {
return false;
}
return (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
}
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 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;
const assetsRef = useRef<EditorAsset[]>([]);
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 {
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,
openEditorLoginModal,
onDeleteAssets: removeCanvasLayersLinkedToAssets,
});
useEffect(() => {
assetsRef.current = assets;
}, [assets]);
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
? (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 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 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) =>
currentDialog?.mode === 'generate' ||
currentDialog?.mode === 'spec' ||
currentDialog?.mode === 'character' ||
currentDialog?.mode === 'icon'
? {
...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,
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: 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 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 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 {
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;
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') {
setShiftPressed(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') {
closeEditorChromePanels();
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') {
setShiftPressed(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);
};
}, [
closeEditorChromePanels,
redoCanvasChange,
setIsSpacePanning,
setShiftPressed,
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);
};
}, []);
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);
};
addAssetLayerRef.current = addAssetLayer;
moveAssetToFolderRef.current = moveAssetToFolder;
deleteLayerByIdRef.current = deleteLayerById;
const handleCanvasDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setUploadDropTarget((currentTarget) =>
currentTarget === 'canvas' ? null : currentTarget,
);
}
};
const handleCanvasDrop = (event: ReactDragEvent<HTMLDivElement>) => {
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.clientX, event.clientY),
);
return;
}
const files = event.dataTransfer.files;
if (!files.length) {
return;
}
event.preventDefault();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
const defaultFolder =
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
addUploadedFiles(files, {
folderId: defaultFolder?.id,
canvasPoint,
addToCanvas: true,
});
};
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>
<UnifiedModal
open={Boolean(metadataLayer)}
title={metadataLayer ? `${metadataLayer.title}图片信息` : '图片信息'}
size="sm"
closeLabel="关闭图片信息"
onClose={() => setMetadataLayer(null)}
panelClassName="image-canvas-editor__metadata-dialog"
bodyClassName="image-canvas-editor__metadata-body"
>
{metadataLayer ? (
<dl className="image-canvas-editor__metadata-grid">
<dt></dt>
<dd>{formatLayerImageType(metadataLayer)}</dd>
<dt></dt>
<dd className="image-canvas-editor__metadata-inputs">
{metadataLayer.generationInputs?.fields.length ||
metadataLayer.generationInputs?.references.length ? (
<>
{metadataLayer.generationInputs.fields.map((field) => (
<div
key={`${field.title}-${field.value}`}
className="image-canvas-editor__metadata-input-field"
>
<span className="image-canvas-editor__metadata-input-title">
{field.title}
</span>
<span>{field.value}</span>
</div>
))}
{metadataLayer.generationInputs.references.length ? (
<div className="image-canvas-editor__metadata-reference-list">
{metadataLayer.generationInputs.references.map(
(reference) => (
<div
key={`${reference.title}-${reference.label}-${reference.src}`}
className="image-canvas-editor__metadata-reference-card"
>
<img
src={reference.src}
alt=""
aria-hidden="true"
/>
<span className="image-canvas-editor__metadata-reference-copy">
<span className="image-canvas-editor__metadata-input-title">
{reference.title}
</span>
<span>{reference.label}</span>
</span>
</div>
),
)}
</div>
) : null}
</>
) : (
'-'
)}
</dd>
<dt>Model</dt>
<dd>{metadataLayer.model ?? '-'}</dd>
<dt>Resolution</dt>
<dd>
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
</dd>
<dt>Provider</dt>
<dd>{metadataLayer.provider ?? '-'}</dd>
<dt>Task</dt>
<dd>{metadataLayer.taskId ?? '-'}</dd>
<dt>Object</dt>
<dd>
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
</dd>
</dl>
) : null}
</UnifiedModal>
</section>
);
}
export default ImageCanvasEditorView;