Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
T
kdletters 37a738e271 拆分图片画布生成对象注册表
新增画布生成对象 dialog 管理 hook
补充生成对象注册表 hook 单测
调整 Lovart 式画布背景色板弹层
更新图片画布前端拆分跟踪文档
2026-06-17 05:29:04 +08:00

4063 lines
127 KiB
TypeScript

import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import JSZip from 'jszip';
import {
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
createEditorAsset,
createEditorAssetFolder,
deleteEditorAsset,
deleteEditorAssetFolder,
editEditorImage,
type EditorIconSpritesheetGenerationResult,
type EditorIconSpritesheetIconResult,
type EditorImageGenerationResult,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
loadEditorAssetLibrary,
renameEditorProject,
updateEditorAsset,
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
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 {
createMinimapModel,
fitViewportToLayers,
getCanvasDropPoint as resolveCanvasDropPoint,
getCanvasPointFromClient as resolveCanvasPointFromClient,
getWorldPointFromClient,
moveGenerationFrameFromDrag,
moveLayersFromDrag,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
moveViewportFromPan,
scaleViewportFromScreenPoint,
scrollViewportVertically,
selectLayersInsideMarquee,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel';
import {
createCanvasLayerClipboard,
duplicateCanvasLayers,
flipCanvasLayers,
getCanvasLayersByIds,
groupCanvasLayers,
moveCanvasLayers,
removeCanvasLayers,
resolveContextTargetLayerIds,
toggleCanvasLayersLock,
toggleCanvasLayersVisibility,
ungroupCanvasLayers,
updateCanvasLayersByIds,
type CanvasLayerFlipAxis,
type CanvasLayerMoveMode,
} from './ImageCanvasLayerCommandModel';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
ASSET_DRAG_MIME_TYPE,
DEFAULT_CANVAS_BACKGROUND_COLOR,
DEFAULT_CANVAS_SIZE,
EDITOR_ASSET_FOLDERS,
TOOLBAR_HALF_WIDTH,
clamp,
createLayerFromAsset,
escapeCssIdentifier,
formatImageSizeValue,
getDraggedAssetId,
hasDataTransferType,
isGeneratedLayer,
isLayerLinkedToAsset,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveContextMenuPosition,
resolveLayerResolutionSize,
serializeLayer,
} from './ImageCanvasEditorModel';
import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerImageBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
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_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,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
DragState,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
SnapGuide,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
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<HTMLElement>) {
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<HTMLElement>) {
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<HTMLElement>) {
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<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
const authUiRef = useRef(authUi);
const isShiftPressedRef = useRef(false);
const layerCounterRef = useRef(0);
const layersRef = useRef<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>({
x: -260,
y: 70,
scale: 0.82,
});
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 [projectTitle, setProjectTitle] = useState('未命名画布');
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
const [isRenamingProject, setIsRenamingProject] = useState(false);
const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false);
const [projectRenameError, setProjectRenameError] = useState<string | null>(
null,
);
const [assetExportStatus, setAssetExportStatus] = useState<{
tone: 'info' | 'success' | 'error';
message: string;
} | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [viewport, setViewport] = useState<CanvasViewport>({
x: -260,
y: 70,
scale: 0.82,
});
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
const [assetFolders, setAssetFolders] =
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
const [assets, setAssets] = useState<EditorAsset[]>([]);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
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<Set<string>>(
() => new Set(),
);
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const [assetPointerDrag, setAssetPointerDrag] =
useState<AssetPointerDragState | null>(null);
const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState<
string | null
>(null);
const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState<
string | null
>(null);
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
null,
);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
const [isSpacePanning, setIsSpacePanning] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(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<CanvasLayer | null>(null);
const [uploadTarget, setUploadTarget] = useState<UploadTarget>('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<ImageContextMenuState | null>(null);
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
null,
);
const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(null);
const [uploadDropTarget, setUploadDropTarget] = useState<
'canvas' | 'assets' | null
>(null);
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
layersRef.current = layers;
viewportRef.current = viewport;
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 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 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) =>
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 clearHistoryDragState = useCallback(() => {
dragStateRef.current = null;
}, []);
const canvasHistoryResetters = useMemo(
() => ({
setHoveredLayerId,
setMetadataLayer,
setCanvasMarquee,
setSnapGuide,
setImageContextMenu,
setContextMenu,
setIsPanning,
clearDragState: clearHistoryDragState,
}),
[clearHistoryDragState],
);
const {
canUndo,
canRedo,
captureCanvasHistory,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
});
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 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 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 minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
);
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);
};
}, []);
const fitLayers = useCallback(
(targetLayers: CanvasLayer[] = layers) => {
const nextViewport = fitViewportToLayers({
layers: targetLayers,
canvasSize,
});
if (!nextViewport) {
return;
}
captureCanvasHistory();
setViewport(nextViewport);
},
[captureCanvasHistory, canvasSize, layers],
);
const updateScaleFromCenter = (nextScale: number) => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
captureCanvasHistory();
setViewport((currentViewport) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: null,
}),
);
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) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: { x: centerX, y: centerY },
}),
);
};
const resolveCanvasPoint = (clientX: number, clientY: number) => {
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
return resolveCanvasPointFromClient({ clientX, clientY, rect });
};
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
resolveCanvasDropPoint({
clientX: event.clientX,
clientY: event.clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
canvasSize,
});
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
return getWorldPointFromClient({
clientX,
clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
viewport,
});
};
const duplicateLayersToPoint = (
sourceLayers: CanvasLayer[],
canvasPoint?: { x: number; y: number },
options: { renameCopies?: boolean } = {},
) =>
duplicateCanvasLayers({
sourceLayers,
allLayers: layersRef.current,
canvasPoint,
renameCopies: options.renameCopies !== false,
});
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 clipboard = createCanvasLayerClipboard(
layers,
targetIds,
options.cut ? 'cut' : 'copy',
);
if (!clipboard) {
return;
}
setCanvasClipboard(clipboard);
if (options.cut) {
captureCanvasHistory();
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
selectSingleLayer(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id)
? null
: currentLayer,
);
}
setContextMenu(null);
};
const duplicateContextLayers = () => {
const targetIds = getContextTargetLayerIds();
const targetLayers = getCanvasLayersByIds(layers, targetIds);
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) =>
updateCanvasLayersByIds(currentLayers, targetIds, updater),
);
setContextMenu(null);
};
const moveContextLayers = (mode: CanvasLayerMoveMode) => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
moveCanvasLayers(currentLayers, targetIds, mode),
);
setContextMenu(null);
};
const groupContextLayers = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
const groupId = `layer-group-${Date.now()}`;
captureCanvasHistory();
setLayers((currentLayers) =>
groupCanvasLayers(currentLayers, targetIds, groupId),
);
setContextMenu(null);
};
const ungroupContextLayers = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
setContextMenu(null);
};
const toggleContextLayerVisibility = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
toggleCanvasLayersVisibility(currentLayers, targetIds),
);
setContextMenu(null);
};
const toggleContextLayerLock = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
toggleCanvasLayersLock(currentLayers, targetIds),
);
setContextMenu(null);
};
const flipContextLayers = (axis: CanvasLayerFlipAxis) => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
flipCanvasLayers(currentLayers, targetIds, axis),
);
setContextMenu(null);
};
const deleteContextLayers = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
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<HTMLElement>('[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<HTMLElement>(
`[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();
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
};
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<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
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<string>();
assetListRef.current
?.querySelectorAll<HTMLElement>('[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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<string>((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) {
appendCanvasLayersWithResources([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 (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,
};
appendCanvasLayersWithResources([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]);
}
};
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,
};
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
setQuickEditPanel(null);
setActiveTool('select');
fitLayers([sourceLayer, 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;
}
appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
removeCanvasGenerationDialogById(dialogId);
}
setActiveTool('select');
};
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 handleNativeWheel = useCallback((event: WheelEvent) => {
event.preventDefault();
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return;
}
if (!event.ctrlKey && !event.metaKey) {
setViewport((currentViewport) =>
scrollViewportVertically(currentViewport, event.deltaY),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const screenPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setViewport((currentViewport) =>
zoomViewportFromWheel({
viewport: currentViewport,
deltaY: event.deltaY,
screenPoint,
}),
);
}, []);
useEffect(() => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return undefined;
}
viewportElement.addEventListener('wheel', handleNativeWheel, {
passive: false,
});
return () => {
viewportElement.removeEventListener('wheel', handleNativeWheel);
};
}, [handleNativeWheel]);
const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>,
) => {
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<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));
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 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 handleLayerPointerDown = (
event: ReactPointerEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => {
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
event.stopPropagation();
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
return;
}
if (button !== 0) {
event.stopPropagation();
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 handleLayerClick = (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => {
// 测试环境和辅助技术可能只触发 click;
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
event.stopPropagation();
if (isPickingCharacterSpecFromCanvas) {
return;
}
if (isPickingIconSpecFromCanvas) {
return;
}
if (event.shiftKey || isShiftPressedRef.current) {
return;
}
selectSingleLayer(layer.id);
setImageContextMenu(null);
};
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 handleCanvasBackgroundHexChange = (nextValue: string) => {
setCanvasBackgroundHexValue(nextValue);
const normalizedColor = normalizeCanvasBackgroundHex(nextValue);
if (normalizedColor) {
setCanvasBackgroundColor(normalizedColor);
setCanvasBackgroundHexValue(normalizedColor);
}
};
const handleGenerationFramePointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => {
if (!dialog.placeholder) {
return;
}
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
event.stopPropagation();
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
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;
}
setViewport((currentViewport) =>
resolveViewportFromMinimapPointer({
viewport: currentViewport,
canvasSize,
minimapModel,
pointer: {
x: clientX - rect.left,
y: clientY - rect.top,
},
}),
);
};
const updateViewportFromMinimapDrag = (
dragState: Extract<DragState, { kind: 'minimap' }>,
clientX: number,
clientY: number,
) => {
setViewport(
resolveViewportFromMinimapDrag(dragState, {
x: clientX,
y: clientY,
}),
);
};
const handleMinimapPointerDown = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
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<HTMLDivElement>) => {
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 selectedIds = selectLayersInsideMarquee({
marquee: canvasMarquee,
currentPoint: { x: currentX, y: currentY },
layers,
viewport,
});
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(moveViewportFromPan(dragState, pointer));
return;
}
if (dragState.kind === 'generation-frame') {
const pointer = getPointerClient(event);
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
currentDialog.placeholder
? {
...currentDialog,
placeholder: {
...currentDialog.placeholder,
x: nextFramePoint.x,
y: nextFramePoint.y,
},
}
: 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) {
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
}
return;
}
const pointer = getPointerClient(event);
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
if (!movedLayers) {
return;
}
setSnapGuide(movedLayers.snapGuide);
setLayers(movedLayers.layers);
};
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
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 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 (
<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={(event) => {
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 ? (
<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}
uploadInputRef={uploadInputRef}
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}
setUploadTarget={setUploadTarget}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
setImageContextMenu={setImageContextMenu}
setContextMenu={setContextMenu}
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
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();
}}
>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
setProjectRenameError(null);
}}
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={() => setIsZoomMenuOpen((open) => !open)}
onCloseZoomMenu={() => setIsZoomMenuOpen(false)}
onToggleBackgroundSettings={() =>
setIsBackgroundSettingsOpen((isOpen) => !isOpen)
}
onApplyCanvasBackgroundColor={applyCanvasBackgroundColor}
onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange}
onToggleSidebarPanel={toggleSidebarPanel}
onToggleMinimap={() => setIsMinimapOpen((open) => !open)}
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={(target) => {
setUploadTarget(target);
uploadInputRef.current?.click();
}}
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;