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

4063 lines
127 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;