Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
kdletters f789499c36 拆分图片画布编辑器侧栏视图
抽出素材和图层左侧整合面板为 ImageCanvasSidebarView

保留上传、登录、拖到画布和持久化状态机在主视图

更新前端拆分计划和 TRACKING 验证记录
2026-06-17 02:17:30 +08:00

6450 lines
221 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 {
Braces,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ClipboardList,
Copy,
Crop,
Download,
Folder,
Hand,
ImageIcon,
ImagePlus,
Info,
Layers,
Map as MapIcon,
MousePointer2,
Pencil,
Redo2,
RotateCcw,
Shapes,
SlidersHorizontal,
Sparkles,
Trash2,
Type,
Undo2,
WandSparkles,
X,
} from 'lucide-react';
import JSZip from 'jszip';
import {
type CSSProperties,
type DragEvent as ReactDragEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { ApiClientError } from '../../services/apiClient';
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
createEditorAsset,
createEditorAssetFolder,
createEditorProjectResource,
deleteEditorAsset,
deleteEditorAssetFolder,
editEditorImage,
type EditorIconSpritesheetGenerationResult,
type EditorIconSpritesheetIconResult,
type EditorImageGenerationResult,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
loadEditorAssetLibrary,
loadEditorProject,
loadOrCreateRecentEditorProject,
renameEditorProject,
saveEditorProjectLayout,
updateEditorAsset,
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import {
ASSET_DRAG_MIME_TYPE,
CANVAS_BACKGROUND_OPTIONS,
CANVAS_WORLD_SIZE,
DEFAULT_CANVAS_BACKGROUND_COLOR,
DEFAULT_CANVAS_SIZE,
EDITOR_ASSET_FOLDERS,
FIT_VIEW_PADDING,
MAX_HISTORY_STEPS,
MAX_SCALE,
MIN_SCALE,
MINIMAP_DRAG_SENSITIVITY,
MINIMAP_PADDING,
MINIMAP_SIZE,
TOOLBAR_HALF_WIDTH,
clamp,
createLayerFromAsset,
escapeCssIdentifier,
formatImageSizeValue,
formatPercent,
getDraggedAssetId,
getLayerBounds,
hasDataTransferType,
hydrateLayer,
isGeneratedLayer,
isLayerLinkedToAsset,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveContextMenuPosition,
resolveLayerResolutionSize,
resolveSnappedLayerPosition,
serializeLayer,
} from './ImageCanvasEditorModel';
import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerImageBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import {
CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
CHARACTER_ANIMATION_RATIO_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
CHARACTER_SPEC_VIEW_OPTIONS,
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_COST,
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,
CanvasHistorySnapshot,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
CanvasViewport,
CharacterAnimationPanelState,
DragState,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
SnapGuide,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
function triggerPlaceholderAction(label: string) {
window.alert(`${label}功能建设中`);
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
): CSSProperties {
const rect = anchor?.getBoundingClientRect();
if (!rect) {
return {
position: 'fixed',
left: 0,
top: 0,
right: 'auto',
bottom: 'auto',
zIndex: 70,
};
}
return {
position: 'fixed',
left: Math.round(rect.left),
top:
placement === 'above'
? Math.round(rect.top)
: Math.round(rect.bottom + 8),
right: 'auto',
bottom: 'auto',
zIndex: 70,
transform:
placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
};
}
function renderEditorPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
return createPortal(node, document.body);
}
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 generationDialogCounterRef = useRef(0);
const saveTimerRef = useRef<number | null>(null);
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
const layersRef = useRef<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>({
x: -260,
y: 70,
scale: 0.82,
});
const projectIdRef = useRef<string | null>(null);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const pendingProjectResourceLayersRef = useRef<
Array<{
layer: CanvasLayer;
options: { onCreated?: (resourceId: string) => void };
}>
>([]);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const generateDialogRef = useRef<GenerateDialogState | null>(null);
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
const suppressAssetClickRef = useRef(false);
const [projectId, setProjectId] = useState<string | null>(null);
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 [isProjectReady, setIsProjectReady] = useState(false);
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 [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
CanvasGenerationDialogState[]
>([]);
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 [historyVersion, setHistoryVersion] = useState(0);
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;
generateDialogRef.current = generateDialog;
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
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 activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog)
? generateDialog
: null;
const canvasGenerationDialogs = useMemo(
() =>
activeCanvasGenerationDialog
? [...inactiveGenerateDialogs, activeCanvasGenerationDialog]
: inactiveGenerateDialogs,
[activeCanvasGenerationDialog, inactiveGenerateDialogs],
);
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) => {
if (menu?.kind !== 'layer') {
return [];
}
return selectedLayerIdsRef.current.includes(menu.layerId)
? selectedLayerIdsRef.current
: [menu.layerId];
},
[contextMenu],
);
const contextTargetIds = getContextTargetLayerIds(contextMenu);
const contextTargetLayers = layers.filter((layer) =>
contextTargetIds.includes(layer.id),
);
const contextShouldShowLayer = contextTargetLayers.some(
(layer) => layer.hidden,
);
const contextShouldUnlockLayer = contextTargetLayers.some(
(layer) => layer.locked,
);
const canUndo = undoStackRef.current.length > 0;
const canRedo = redoStackRef.current.length > 0;
void historyVersion;
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 createGenerationDialogId = () => {
generationDialogCounterRef.current += 1;
return `generation-dialog-${generationDialogCounterRef.current}`;
};
const archiveActiveCanvasGenerationDialog = () => {
const currentDialog = generateDialogRef.current;
if (!isCanvasGenerationDialog(currentDialog)) {
return;
}
setInactiveGenerateDialogs((currentDialogs) =>
currentDialogs.some((dialog) => dialog.id === currentDialog.id)
? currentDialogs
: [
...currentDialogs,
{
...currentDialog,
composerOpen: false,
},
],
);
};
const openCanvasGenerationDialog = (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => {
archiveActiveCanvasGenerationDialog();
setGenerateDialog({
...dialog,
id: createGenerationDialogId(),
});
};
const updateCanvasGenerationDialogById = (
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => {
setGenerateDialog((currentDialog) =>
isCanvasGenerationDialog(currentDialog) &&
currentDialog.id === dialogId
? updater(currentDialog)
: currentDialog,
);
setInactiveGenerateDialogs((currentDialogs) =>
currentDialogs.flatMap((dialog) => {
if (dialog.id !== dialogId) {
return [dialog];
}
const nextDialog = updater(dialog);
return nextDialog ? [nextDialog] : [];
}),
);
};
const removeCanvasGenerationDialogById = (dialogId: string) => {
updateCanvasGenerationDialogById(dialogId, () => null);
};
const activateCanvasGenerationDialog = (
targetDialog: CanvasGenerationDialogState,
) => {
setInactiveGenerateDialogs((currentDialogs) => {
const nextDialogs = currentDialogs.filter(
(dialog) => dialog.id !== targetDialog.id,
);
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialog(currentDialog) &&
currentDialog.id !== targetDialog.id
) {
nextDialogs.push({
...currentDialog,
composerOpen: false,
});
}
return nextDialogs;
});
setGenerateDialog({
...targetDialog,
composerOpen: true,
});
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
};
const removeCanvasGenerationDialogsByLayerId = (targetLayerId: string) => {
const keepDialog = (dialog: CanvasGenerationDialogState) =>
dialog.sourceLayerId !== targetLayerId &&
dialog.generatedLayerId !== targetLayerId;
setGenerateDialog((currentDialog) =>
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
? null
: currentDialog,
);
setInactiveGenerateDialogs((currentDialogs) =>
currentDialogs.filter(keepDialog),
);
};
const getCanvasHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({
layers: layersRef.current.map((layer) => ({ ...layer })),
viewport: { ...viewportRef.current },
generateDialog: generateDialogRef.current
? {
...generateDialogRef.current,
placeholder: generateDialogRef.current.placeholder
? { ...generateDialogRef.current.placeholder }
: undefined,
}
: null,
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
(dialog) => ({
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
}),
),
selectedLayerId: selectedLayerIdRef.current,
selectedLayerIds: [...selectedLayerIdsRef.current],
}),
[],
);
const restoreCanvasHistorySnapshot = useCallback(
(snapshot: CanvasHistorySnapshot) => {
setLayers(snapshot.layers.map((layer) => ({ ...layer })));
setViewport({ ...snapshot.viewport });
setGenerateDialog(
snapshot.generateDialog
? {
...snapshot.generateDialog,
placeholder: snapshot.generateDialog.placeholder
? { ...snapshot.generateDialog.placeholder }
: undefined,
}
: null,
);
setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map((dialog) => ({
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
})),
);
setSelectedLayerId(snapshot.selectedLayerId);
setSelectedLayerIds([...snapshot.selectedLayerIds]);
setHoveredLayerId(null);
setMetadataLayer(null);
setCanvasMarquee(null);
setSnapGuide(null);
setImageContextMenu(null);
setContextMenu(null);
setIsPanning(false);
dragStateRef.current = null;
},
[],
);
const captureCanvasHistory = useCallback(
(options: { clearRedo?: boolean } = {}) => {
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
if (options.clearRedo !== false) {
redoStackRef.current = [];
}
setHistoryVersion((version) => version + 1);
},
[getCanvasHistorySnapshot],
);
const undoCanvasChange = useCallback(() => {
const previousSnapshot = undoStackRef.current.at(-1);
if (!previousSnapshot) {
return;
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(previousSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
const redoCanvasChange = useCallback(() => {
const nextSnapshot = redoStackRef.current.at(-1);
if (!nextSnapshot) {
return;
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
getCanvasHistorySnapshot(),
];
restoreCanvasHistorySnapshot(nextSnapshot);
setHistoryVersion((version) => version + 1);
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
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 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 getGeneratingDialogPlaceholder = useCallback(
(dialog: GenerateDialogState) => {
const currentDialog = generateDialogRef.current;
if (dialog.id) {
const latestDialog = [
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
...inactiveGenerateDialogsRef.current,
].find((candidateDialog) => candidateDialog.id === dialog.id);
if (latestDialog?.status === 'generating') {
return latestDialog.placeholder ?? dialog.placeholder;
}
}
if (
currentDialog?.mode === dialog.mode &&
(!dialog.id || currentDialog.id === dialog.id) &&
currentDialog.status === 'generating'
) {
return currentDialog.placeholder ?? dialog.placeholder;
}
return dialog.placeholder;
},
[],
);
const createProjectResourceForLayer = useCallback(
(
layer: CanvasLayer,
options: { onCreated?: (resourceId: string) => void } = {},
) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
pendingProjectResourceLayersRef.current.push({ layer, options });
return;
}
createEditorProjectResource(readyProjectId, {
imageSrc: layer.src,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
sourceResourceId: layer.sourceResourceId,
})
.then((resource) => {
options.onCreated?.(resource.resourceId);
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
}
: currentLayer,
),
);
})
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
},
[openEditorLoginModal],
);
const minimapModel = useMemo(() => {
const layerBounds = getLayerBounds(layers);
if (!layerBounds) {
return null;
}
const visibleBounds = {
minX: (0 - viewport.x) / viewport.scale,
minY: (0 - viewport.y) / viewport.scale,
maxX: (canvasSize.width - viewport.x) / viewport.scale,
maxY: (canvasSize.height - viewport.y) / viewport.scale,
};
const bounds = {
minX: Math.min(layerBounds.minX, visibleBounds.minX),
minY: Math.min(layerBounds.minY, visibleBounds.minY),
maxX: Math.max(layerBounds.maxX, visibleBounds.maxX),
maxY: Math.max(layerBounds.maxY, visibleBounds.maxY),
};
const boundsWidth = Math.max(1, bounds.maxX - bounds.minX);
const boundsHeight = Math.max(1, bounds.maxY - bounds.minY);
const scale = Math.min(
(MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth,
(MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight,
);
const projectRect = (rect: {
minX: number;
minY: number;
maxX: number;
maxY: number;
}) => ({
left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale,
top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale,
width: Math.max(2, (rect.maxX - rect.minX) * scale),
height: Math.max(2, (rect.maxY - rect.minY) * scale),
});
return {
bounds,
scale,
layers: layers.map((layer) => ({
id: layer.id,
title: layer.title,
rect: projectRect({
minX: layer.x,
minY: layer.y,
maxX: layer.x + layer.width,
maxY: layer.y + layer.height,
}),
})),
viewport: projectRect(visibleBounds),
};
}, [canvasSize.height, canvasSize.width, layers, viewport]);
useEffect(() => {
let cancelled = false;
const projectIdFromQuery =
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search)
.get('projectid')
?.trim() || null;
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
if (cancelled) {
return;
}
projectIdRef.current = project.projectId;
setProjectId(project.projectId);
const nextProjectTitle = project.title?.trim() || '未命名画布';
setProjectTitle(nextProjectTitle);
setProjectRenameValue(nextProjectTitle);
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
pendingLayers.forEach(({ layer, options }) => {
createProjectResourceForLayer(layer, options);
});
setViewport(project.viewport);
const resourcesById = new Map(
project.resources.map((resource) => [
resource.resourceId,
{ imageSrc: resource.imageSrc },
]),
);
const hydratedLayers = project.layers
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
if (hydratedLayers.length > 0) {
layerCounterRef.current = hydratedLayers.length;
setLayers(hydratedLayers);
selectSingleLayer(hydratedLayers[0]?.id ?? null);
}
setIsProjectReady(true);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setIsProjectReady(false);
if (isEditorAuthError(error)) {
openEditorLoginModal(() => {
window.location.reload();
});
}
});
return () => {
cancelled = true;
};
}, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]);
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);
};
}, []);
useEffect(() => {
if (!projectId || !isProjectReady) {
return undefined;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, {
viewport,
layers: layers.map(serializeLayer),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}, 450);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
};
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
const fitLayers = useCallback(
(targetLayers: CanvasLayer[] = layers) => {
if (targetLayers.length === 0) {
return;
}
const bounds = getLayerBounds(targetLayers);
if (!bounds) {
return;
}
const boundsWidth = Math.max(1, bounds.maxX - bounds.minX);
const boundsHeight = Math.max(1, bounds.maxY - bounds.minY);
const availableWidth = Math.max(
1,
canvasSize.width - FIT_VIEW_PADDING * 2,
);
const availableHeight = Math.max(
1,
canvasSize.height - FIT_VIEW_PADDING * 2,
);
const scale = clamp(
Math.min(
1,
availableWidth / boundsWidth,
availableHeight / boundsHeight,
),
MIN_SCALE,
MAX_SCALE,
);
captureCanvasHistory();
setViewport({
x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale,
y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale,
scale,
});
},
[captureCanvasHistory, canvasSize.height, canvasSize.width, layers],
);
const updateScaleFromCenter = (nextScale: number) => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
captureCanvasHistory();
setViewport((currentViewport) => ({
...currentViewport,
scale: clamp(nextScale, MIN_SCALE, MAX_SCALE),
}));
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) => {
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
const worldX = (centerX - currentViewport.x) / currentViewport.scale;
const worldY = (centerY - currentViewport.y) / currentViewport.scale;
return {
x: centerX - worldX * scale,
y: centerY - worldY * scale,
scale,
};
});
};
const resolveCanvasPoint = (clientX: number, clientY: number) => {
const rect = canvasViewportRef.current?.getBoundingClientRect();
if (!rect) {
return null;
}
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
return null;
}
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
};
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
resolveCanvasPoint(event.clientX, event.clientY) ?? {
x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0,
y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0,
};
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
const rect = canvasViewportRef.current?.getBoundingClientRect();
const screenX = clientX - (rect?.left ?? 0);
const screenY = clientY - (rect?.top ?? 0);
return {
x: (screenX - viewport.x) / viewport.scale,
y: (screenY - viewport.y) / viewport.scale,
};
};
const duplicateLayersToPoint = (
sourceLayers: CanvasLayer[],
canvasPoint?: { x: number; y: number },
options: { renameCopies?: boolean } = {},
) => {
if (!sourceLayers.length) {
return [];
}
const minX = Math.min(...sourceLayers.map((layer) => layer.x));
const minY = Math.min(...sourceLayers.map((layer) => layer.y));
const maxZIndex = layersRef.current.reduce(
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
0,
);
const stamp = Date.now();
return sourceLayers.map((layer, index) => ({
...layer,
id: `layer-copy-${stamp}-${index}`,
resourceId: `local-resource-copy-${stamp}-${index}`,
title: options.renameCopies === false ? layer.title : `${layer.title} 副本`,
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
zIndex: maxZIndex + index + 1,
groupId: null,
}));
};
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 targetLayers = layers.filter((layer) => targetIds.includes(layer.id));
if (!targetLayers.length) {
return;
}
setCanvasClipboard({
layers: targetLayers.map((layer) => ({ ...layer })),
mode: options.cut ? 'cut' : 'copy',
});
if (options.cut) {
captureCanvasHistory();
setLayers((currentLayers) =>
currentLayers.filter((layer) => !targetIds.includes(layer.id)),
);
selectSingleLayer(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id)
? null
: currentLayer,
);
}
setContextMenu(null);
};
const duplicateContextLayers = () => {
const targetIds = getContextTargetLayerIds();
const targetLayers = layers.filter((layer) => targetIds.includes(layer.id));
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) =>
currentLayers.map((layer) =>
targetIds.includes(layer.id) ? updater(layer, targetIds) : layer,
),
);
setContextMenu(null);
};
const moveContextLayers = (mode: 'up' | 'down' | 'top' | 'bottom') => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
const maxZIndex = layers.reduce(
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
0,
);
const minZIndex = layers.reduce(
(minZ, layer) => Math.min(minZ, layer.zIndex),
0,
);
let offsetIndex = 0;
updateContextLayers((layer) => {
if (mode === 'up') {
return { ...layer, zIndex: layer.zIndex + 1 };
}
if (mode === 'down') {
return { ...layer, zIndex: layer.zIndex - 1 };
}
offsetIndex += 1;
if (mode === 'top') {
return { ...layer, zIndex: maxZIndex + offsetIndex };
}
return { ...layer, zIndex: minZIndex - (targetIds.length - offsetIndex + 1) };
});
};
const groupContextLayers = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
const groupId = `layer-group-${Date.now()}`;
updateContextLayers((layer) => ({
...layer,
groupId,
}));
};
const ungroupContextLayers = () => {
updateContextLayers((layer) => ({
...layer,
groupId: null,
}));
};
const toggleContextLayerVisibility = () => {
const targetIds = getContextTargetLayerIds();
const shouldHide = layers
.filter((layer) => targetIds.includes(layer.id))
.some((layer) => !layer.hidden);
updateContextLayers((layer) => ({
...layer,
hidden: shouldHide,
}));
};
const toggleContextLayerLock = () => {
const targetIds = getContextTargetLayerIds();
const shouldLock = layers
.filter((layer) => targetIds.includes(layer.id))
.some((layer) => !layer.locked);
updateContextLayers((layer) => ({
...layer,
locked: shouldLock,
}));
};
const flipContextLayers = (axis: 'x' | 'y') => {
updateContextLayers((layer) =>
axis === 'x'
? {
...layer,
flipX: !layer.flipX,
}
: {
...layer,
flipY: !layer.flipY,
},
);
};
const deleteContextLayers = () => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
currentLayers.filter((layer) => !targetIds.includes(layer.id)),
);
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();
setLayers((currentLayers) => [...currentLayers, nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
createProjectResourceForLayer(nextLayer);
};
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) {
setLayers((currentLayers) => [...currentLayers, 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 (options.addToCanvas) {
createProjectResourceForLayer(nextLayer);
}
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,
};
setLayers((currentLayers) => [...currentLayers, 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]);
}
createProjectResourceForLayer(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,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
setQuickEditPanel(null);
setActiveTool('select');
fitLayers([sourceLayer, nextLayer]);
createProjectResourceForLayer(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;
}
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
removeCanvasGenerationDialogById(dialogId);
}
setActiveTool('select');
nextLayers.forEach((layer) => createProjectResourceForLayer(layer));
};
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 handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return;
}
if (!event.ctrlKey && !event.metaKey) {
setViewport((currentViewport) => ({
...currentViewport,
y: currentViewport.y - event.deltaY,
}));
return;
}
const rect = viewportElement.getBoundingClientRect();
const pointerX = event.clientX - rect.left;
const pointerY = event.clientY - rect.top;
const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1;
setViewport((currentViewport) => {
const nextScale = clamp(
currentViewport.scale * scaleMultiplier,
MIN_SCALE,
MAX_SCALE,
);
const worldX = (pointerX - currentViewport.x) / currentViewport.scale;
const worldY = (pointerY - currentViewport.y) / currentViewport.scale;
return {
x: pointerX - worldX * nextScale,
y: pointerY - worldY * nextScale,
scale: nextScale,
};
});
};
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 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) {
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 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;
}
const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width);
const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height);
const worldX =
minimapModel.bounds.minX +
(localX - MINIMAP_PADDING) / minimapModel.scale;
const worldY =
minimapModel.bounds.minY +
(localY - MINIMAP_PADDING) / minimapModel.scale;
setViewport((currentViewport) => ({
...currentViewport,
x: canvasSize.width / 2 - worldX * currentViewport.scale,
y: canvasSize.height / 2 - worldY * currentViewport.scale,
}));
};
const moveViewportFromMinimapDrag = (
dragState: Extract<DragState, { kind: 'minimap' }>,
clientX: number,
clientY: number,
) => {
const deltaWorldX =
((clientX - dragState.startClientX) / dragState.minimapScale) *
MINIMAP_DRAG_SENSITIVITY;
const deltaWorldY =
((clientY - dragState.startClientY) / dragState.minimapScale) *
MINIMAP_DRAG_SENSITIVITY;
setViewport({
...dragState.startViewport,
x:
dragState.startViewport.x -
deltaWorldX * dragState.startViewport.scale,
y:
dragState.startViewport.y -
deltaWorldY * dragState.startViewport.scale,
});
};
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 left = Math.min(canvasMarquee.startX, currentX);
const right = Math.max(canvasMarquee.startX, currentX);
const top = Math.min(canvasMarquee.startY, currentY);
const bottom = Math.max(canvasMarquee.startY, currentY);
const selectedIds = layers
.filter((layer) => {
const layerLeft = viewport.x + layer.x * viewport.scale;
const layerTop = viewport.y + layer.y * viewport.scale;
const layerRight = layerLeft + layer.width * viewport.scale;
const layerBottom = layerTop + layer.height * viewport.scale;
return (
layerLeft <= right &&
layerRight >= left &&
layerTop <= bottom &&
layerBottom >= top
);
})
.map((layer) => layer.id);
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({
...dragState.startViewport,
x: dragState.startViewport.x + pointer.x - dragState.startClientX,
y: dragState.startViewport.y + pointer.y - dragState.startClientY,
});
return;
}
if (dragState.kind === 'generation-frame') {
const pointer = getPointerClient(event);
const deltaX =
(pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY =
(pointer.y - dragState.startClientY) / dragState.startScale;
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
currentDialog.placeholder
? {
...currentDialog,
placeholder: {
...currentDialog.placeholder,
x: dragState.startFrameX + deltaX,
y: dragState.startFrameY + deltaY,
},
}
: 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) {
moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
}
return;
}
const movingLayer = layers.find((layer) => layer.id === dragState.layerId);
if (!movingLayer) {
return;
}
const pointer = getPointerClient(event);
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
const snapped = resolveSnappedLayerPosition(
movingLayer,
dragState.startLayerX + deltaX,
dragState.startLayerY + deltaY,
layers,
dragState.startScale,
);
setSnapGuide(snapped.guide);
setLayers((currentLayers) =>
currentLayers.map((layer) =>
dragState.layerIds.includes(layer.id)
? (() => {
const startLayer = dragState.startLayers.find(
(item) => item.id === layer.id,
);
if (!startLayer) {
return layer;
}
if (layer.id === dragState.layerId) {
return {
...layer,
x: snapped.x,
y: snapped.y,
};
}
return {
...layer,
x:
startLayer.x +
deltaX +
(snapped.x - (dragState.startLayerX + deltaX)),
y:
startLayer.y +
deltaY +
(snapped.y - (dragState.startLayerY + deltaY)),
};
})()
: layer,
),
);
};
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 toolButtons = [
{ label: '裁剪', icon: Crop },
{ label: '重绘', icon: Sparkles },
{ label: '调整', icon: SlidersHorizontal },
{ label: '复制', icon: Copy },
];
const canvasTools: Array<{
id: CanvasTool;
label: string;
icon: typeof MousePointer2;
}> = [
{ id: 'select', label: '选择工具', icon: MousePointer2 },
{ id: 'hand', label: '抓手工具', icon: Hand },
{ id: 'upload', label: '上传工具', icon: ImagePlus },
{ id: 'generate', label: '生成工具', icon: WandSparkles },
{ id: 'spec', label: '生成规范', icon: ClipboardList },
{ id: 'character', label: '生成角色形象', icon: Sparkles },
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
{ id: 'text', label: '文字工具', icon: Type },
{ id: 'shape', label: '形状标注工具', icon: Shapes },
{ id: 'export', label: '导出工具', icon: Download },
];
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>
<div
ref={canvasViewportRef}
className={`image-canvas-editor__viewport ${isPanning ? 'image-canvas-editor__viewport--panning' : ''} image-canvas-editor__viewport--tool-${effectiveTool}`}
style={{ backgroundColor: canvasBackgroundColor }}
aria-label="画布工作区"
onPointerDown={handleCanvasPointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
onWheel={handleWheel}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
const position = resolveContextMenuPosition(
event.clientX,
event.clientY,
'blank',
);
setImageContextMenu(null);
setContextMenu({
kind: 'blank',
...position,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
});
}}
>
{uploadDropTarget === 'canvas' ? (
<div
className="image-canvas-editor__upload-drop-overlay image-canvas-editor__upload-drop-overlay--canvas"
role="status"
>
<span></span>
<strong></strong>
</div>
) : null}
<div
className="image-canvas-editor__world"
style={{
width: CANVAS_WORLD_SIZE,
height: CANVAS_WORLD_SIZE,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
{snapGuide?.vertical !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
data-testid="image-canvas-editor-snap-guide-vertical"
style={{ left: snapGuide.vertical }}
/>
) : null}
{snapGuide?.horizontal !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
data-testid="image-canvas-editor-snap-guide-horizontal"
style={{ top: snapGuide.horizontal }}
/>
) : null}
{layers
.slice()
.filter((layer) => !layer.hidden)
.sort((left, right) => left.zIndex - right.zIndex)
.map((layer) => {
const isSelected = selectedLayerIds.includes(layer.id);
const isHovered = hoveredLayerId === layer.id;
const kindLabel = getLayerKindLabel(layer);
const layerGeneratingLabel =
generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
? '修改中'
: quickEditPanel?.status === 'generating' &&
quickEditPanel.sourceLayerId === layer.id
? '生成中'
: null;
return (
<button
key={layer.id}
type="button"
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : undefined,
}}
onPointerDown={(event) =>
handleLayerPointerDown(event, layer)
}
onClick={(event) => {
// 测试环境和辅助技术可能只触发 click
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
event.stopPropagation();
if (isPickingCharacterSpecFromCanvas) {
return;
}
if (isPickingIconSpecFromCanvas) {
return;
}
if (event.shiftKey || isShiftPressedRef.current) {
return;
}
selectSingleLayer(layer.id);
setImageContextMenu(null);
}}
onContextMenu={(event) => {
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,
});
}}
onMouseEnter={() => setHoveredLayerId(layer.id)}
onMouseLeave={() =>
setHoveredLayerId((currentId) =>
currentId === layer.id ? null : currentId,
)
}
aria-label={`选择${layer.title}`}
>
<img
src={layer.src}
alt={`画布图片:${layer.title}`}
style={{
transform:
layer.flipX || layer.flipY
? `scale(${layer.flipX ? -1 : 1}, ${
layer.flipY ? -1 : 1
})`
: undefined,
}}
/>
{kindLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className={`image-canvas-editor__metadata-corner ${
kindLabel
? 'image-canvas-editor__metadata-corner--beside-kind'
: ''
}`}
label={`查看${layer.title}图片信息`}
icon={<Braces className="h-3 w-3" />}
onClick={(event) => {
event.stopPropagation();
setMetadataLayer(layer);
selectSingleLayer(layer.id);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
>
{Math.round(layer.originalWidth)} x{' '}
{Math.round(layer.originalHeight)} px
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</button>
);
})}
{canvasMarquee ? (
<div
className="image-canvas-editor__canvas-marquee"
aria-hidden="true"
style={{
left:
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
viewport.x) /
viewport.scale,
top:
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
viewport.y) /
viewport.scale,
width:
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
viewport.scale,
height:
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
viewport.scale,
}}
/>
) : null}
{canvasGenerationDialogs.map((dialog) =>
dialog.placeholder ? (
<div
key={dialog.id}
className={`image-canvas-editor__generation-frame ${
dialog.mode === 'icon'
? 'image-canvas-editor__generation-frame--icon'
: ''
} ${
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
}`}
role="button"
tabIndex={0}
style={{
left: dialog.placeholder.x,
top: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
}}
aria-label={getGenerationFrameAriaLabel(dialog)}
onPointerDown={(event) =>
handleGenerationFramePointerDown(event, dialog)
}
onDoubleClick={() => activateCanvasGenerationDialog(dialog)}
>
<span className="image-canvas-editor__generation-frame-label">
<ImageIcon className="h-4 w-4" />
{getGenerationFrameLabel(dialog)}
</span>
{dialog.mode === 'character' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
</span>
) : null}
{dialog.mode === 'spec' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
</span>
) : null}
{dialog.mode === 'icon' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
</span>
) : null}
<span className="image-canvas-editor__generation-frame-size">
{dialog.placeholder.originalWidth} x{' '}
{dialog.placeholder.originalHeight}
</span>
<span className="image-canvas-editor__generation-frame-icon">
<ImageIcon className="h-8 w-8" />
</span>
{dialog.status === 'generating' ? (
<span
className="image-canvas-editor__generation-frame-progress"
role="status"
>
</span>
) : null}
</div>
) : null,
)}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' ||
generateDialog?.mode === 'icon') &&
generateDialog.status === 'generating' &&
generationComposerStyle ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
role="status"
style={generationComposerStyle}
>
</PlatformStatusMessage>
) : null}
</div>
{selectedLayer && selectedToolbarStyle ? (
<div
className="image-canvas-editor__floating-toolbar"
style={selectedToolbarStyle}
role="toolbar"
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{toolButtons.map(({ label, icon: Icon }) => (
<EditorIconButton
key={label}
label={`${label}占位`}
title={`${label}占位`}
icon={Icon}
onClick={() => triggerPlaceholderAction(label)}
/>
))}
<EditorIconButton
label="删除图片"
title="删除图片"
icon={Trash2}
onClick={deleteSelectedLayer}
/>
<EditorIconButton
label="快速编辑"
title="快速编辑"
icon={Sparkles}
onClick={() => openQuickEditPanel(selectedLayer)}
/>
{isGeneratedLayer(selectedLayer) ? (
<>
<EditorIconButton
label={`查看${selectedLayer.title}图片信息`}
title={`查看${selectedLayer.title}图片信息`}
icon={Info}
onClick={() => setMetadataLayer(selectedLayer)}
/>
<EditorIconButton
label="修改图片"
title="修改图片"
icon={WandSparkles}
onClick={() => openEditDialog(selectedLayer)}
/>
</>
) : null}
{selectedLayer.assetKind === 'character' ? (
<EditorIconButton
label="生成动画"
title="生成动画"
icon={Sparkles}
onClick={() => openCharacterAnimationPanel(selectedLayer)}
/>
) : null}
</div>
) : null}
{contextMenu ? (
<div
className="image-canvas-editor__context-menu"
role="menu"
aria-label={
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
}
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === 'blank' ? (
<>
<button
type="button"
role="menuitem"
disabled={!canvasClipboard?.layers.length}
onClick={() => pasteCanvasClipboard(contextMenu.canvasPoint)}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
updateScaleFromCenter(viewport.scale * 1.16);
setContextMenu(null);
}}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
updateScaleFromCenter(viewport.scale * 0.86);
setContextMenu(null);
}}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
fitLayers();
setContextMenu(null);
}}
>
</button>
</>
) : (
<>
<button type="button" role="menuitem" onClick={() => copyContextLayers()}>
</button>
<button
type="button"
role="menuitem"
onClick={() => copyContextLayers({ cut: true })}
>
</button>
<button
type="button"
role="menuitem"
disabled={!canvasClipboard?.layers.length}
onClick={() => pasteCanvasClipboard(contextMenu.canvasPoint)}
>
</button>
<button type="button" role="menuitem" onClick={duplicateContextLayers}>
</button>
<hr />
<button type="button" role="menuitem" onClick={() => moveContextLayers('up')}>
</button>
<button type="button" role="menuitem" onClick={() => moveContextLayers('down')}>
</button>
<button type="button" role="menuitem" onClick={() => moveContextLayers('top')}>
</button>
<button type="button" role="menuitem" onClick={() => moveContextLayers('bottom')}>
</button>
<hr />
<button type="button" role="menuitem" onClick={groupContextLayers}>
</button>
<button type="button" role="menuitem" onClick={ungroupContextLayers}>
</button>
<button type="button" role="menuitem" onClick={toggleContextLayerVisibility}>
{contextShouldShowLayer ? '显示' : '隐藏'}
</button>
<button type="button" role="menuitem" onClick={toggleContextLayerLock}>
{contextShouldUnlockLayer ? '解锁' : '锁定'}
</button>
<hr />
<button type="button" role="menuitem" onClick={() => flipContextLayers('x')}>
</button>
<button type="button" role="menuitem" onClick={() => flipContextLayers('y')}>
</button>
<button type="button" role="menuitem" onClick={exportContextLayer}>
</button>
<hr />
{imageContextMenuLayer ? (
<>
<button
type="button"
role="menuitem"
onClick={() => openQuickEditPanel(imageContextMenuLayer)}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setMetadataLayer(imageContextMenuLayer);
setContextMenu(null);
setImageContextMenu(null);
}}
>
</button>
{imageContextMenuLayer.assetKind === 'character' ? (
<button
type="button"
role="menuitem"
onClick={() =>
openCharacterAnimationPanel(imageContextMenuLayer)
}
>
</button>
) : null}
<hr />
</>
) : null}
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={deleteContextLayers}
>
</button>
</>
)}
</div>
) : null}
<EditorIconButton
className="image-canvas-editor__reset-button"
label="重置画布视图"
title="重置画布视图"
icon={RotateCcw}
onClick={() => fitLayers()}
/>
<div
className="image-canvas-editor__panel-dock"
role="toolbar"
aria-label="画布面板入口"
onPointerDown={(event) => event.stopPropagation()}
>
<EditorIconButton
label="撤销"
title="撤销"
icon={Undo2}
disabled={!canUndo}
onClick={undoCanvasChange}
/>
<EditorIconButton
label="重做"
title="重做"
icon={Redo2}
disabled={!canRedo}
onClick={redoCanvasChange}
/>
<div className="image-canvas-editor__zoom-menu-wrap">
<PlatformInlineOptionButton
className="image-canvas-editor__zoom-trigger"
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
aria-haspopup="menu"
aria-expanded={isZoomMenuOpen}
onClick={() => setIsZoomMenuOpen((open) => !open)}
>
{formatPercent(viewport.scale)}
</PlatformInlineOptionButton>
{isZoomMenuOpen ? (
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
updateScaleFromCenter(viewport.scale * 1.16);
setIsZoomMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
updateScaleFromCenter(viewport.scale * 0.86);
setIsZoomMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
fitLayers();
setIsZoomMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
{[0.5, 1, 2].map((scale) => (
<PlatformFloatingMenuItem
key={scale}
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
updateScaleFromCenter(scale);
setIsZoomMenuOpen(false);
}}
>
{Math.round(scale * 100)}%
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>
) : null}
</div>
<div className="image-canvas-editor__background-control">
<PlatformIconButton
label="画布背景色"
title="画布背景色"
aria-expanded={isBackgroundSettingsOpen}
onClick={() =>
setIsBackgroundSettingsOpen((isOpen) => !isOpen)
}
icon={
<span
className="image-canvas-editor__background-swatch-current"
style={{ backgroundColor: canvasBackgroundColor }}
/>
}
/>
{isBackgroundSettingsOpen ? (
<div
className="image-canvas-editor__background-panel"
role="dialog"
aria-label="画布背景设置"
>
<div className="image-canvas-editor__background-panel-head">
<span></span>
<span
className="image-canvas-editor__background-preview"
aria-label={`当前画布背景色 ${canvasBackgroundColor}`}
style={{ backgroundColor: canvasBackgroundColor }}
/>
</div>
<div
className="image-canvas-editor__background-presets"
aria-label="画布背景预设色"
>
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="image-canvas-editor__background-preset"
aria-label={option.label}
aria-pressed={canvasBackgroundColor === option.value}
onClick={() => applyCanvasBackgroundColor(option.value)}
>
<span
className="image-canvas-editor__background-swatch"
style={{ backgroundColor: option.value }}
/>
<span>{option.label}</span>
</button>
))}
</div>
<label className="image-canvas-editor__background-field">
<span></span>
<input
type="color"
aria-label="自定义画布背景色"
value={canvasBackgroundColor}
onChange={(event) =>
applyCanvasBackgroundColor(event.currentTarget.value)
}
/>
</label>
<label className="image-canvas-editor__background-field image-canvas-editor__background-field--hex">
<span>HEX</span>
<PlatformTextField
aria-label="画布背景十六进制颜色"
value={canvasBackgroundHexValue}
density="compact"
size="xs"
spellCheck={false}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setCanvasBackgroundHexValue(nextValue);
const normalizedColor =
normalizeCanvasBackgroundHex(nextValue);
if (normalizedColor) {
setCanvasBackgroundColor(normalizedColor);
setCanvasBackgroundHexValue(normalizedColor);
}
}}
/>
</label>
<PlatformActionButton
tone="secondary"
size="xs"
className="image-canvas-editor__background-reset"
onClick={() =>
applyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</PlatformActionButton>
</div>
) : null}
</div>
<EditorIconButton
label="打开素材"
title="素材"
icon={ImagePlus}
pressed={activeSidebarPanel === 'assets'}
onClick={() => toggleSidebarPanel('assets')}
/>
<EditorIconButton
label="打开图层"
title="图层"
icon={Layers}
pressed={activeSidebarPanel === 'layers'}
onClick={() => toggleSidebarPanel('layers')}
/>
<EditorIconButton
label="切换小地图"
title="小地图"
icon={MapIcon}
pressed={isMinimapOpen}
onClick={() => setIsMinimapOpen((open) => !open)}
/>
</div>
{isMinimapOpen && minimapModel ? (
<button
type="button"
className="image-canvas-editor__minimap"
aria-label="画布小地图"
title="拖拽移动视图"
onPointerDown={handleMinimapPointerDown}
>
<span className="image-canvas-editor__minimap-stage">
{minimapModel.layers.map((layer) => (
<span
key={layer.id}
className="image-canvas-editor__minimap-layer"
title={layer.title}
style={layer.rect}
/>
))}
<span
className="image-canvas-editor__minimap-viewport"
style={minimapModel.viewport}
/>
</span>
</button>
) : null}
<div
className="image-canvas-editor__bottom-toolbar"
role="toolbar"
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{canvasTools.map(({ id, label, icon: Icon }) =>
id === 'spec' ? (
<span
key={id}
ref={specToolWrapRef}
className="image-canvas-editor__spec-tool-wrap"
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
onClick={() => switchTool(id)}
/>
</span>
) : (
<EditorIconButton
key={id}
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
onClick={() => switchTool(id)}
/>
),
)}
</div>
{isSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="生成规范类型"
placement="top-start"
style={buildPortalMenuStyle(specToolWrapRef.current, 'above')}
>
{(['character', 'ui', 'custom'] as const).map((specType) => (
<PlatformFloatingMenuItem
key={specType}
className="image-canvas-editor__spec-menu-item"
onClick={() => openSpecDialog(specType)}
>
{SPEC_TYPE_LABEL[specType]}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{generateDialog?.mode === 'generate' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<form
className="image-canvas-editor__generation-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitImageGeneration(generateDialog);
}
}}
>
<PlatformIconButton
variant="surfaceFloating"
className="image-canvas-editor__generation-ref"
label="添加参考图"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setUploadTarget('asset');
uploadInputRef.current?.click();
}}
icon={<ImageIcon className="h-4 w-4" />}
>
<span></span>
</PlatformIconButton>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
placeholder="今天我们要创作什么"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
)
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
aria-label="生成比例 1:1 2k 1张"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('生成参数')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
· 1:1(2k) · 1
</PlatformInlineOptionButton>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
aria-label="生成模型 GPT Image"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('模型选择')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
GPT Im...
</PlatformInlineOptionButton>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
aria-label="生成"
>
{generateDialog.status === 'generating' ? '生成中' : '12'}
</PlatformActionButton>
</div>
{generateDialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
</PlatformStatusMessage>
) : null}
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<EditorIconButton
className="image-canvas-editor__generation-close"
label="关闭生成图片"
icon={X}
variant="surfaceFloating"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
setActiveTool('select');
}}
/>
</form>
) : null}
{generateDialog?.mode === 'spec' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__spec-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成规范"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__spec-fields">
{generateDialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="自定义规范提示词"
value={generateDialog.specValues?.customPrompt ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
onChange={(event) =>
updateSpecFormValue('customPrompt', event.target.value)
}
/>
</label>
) : (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label="玩法设定"
value={generateDialog.specValues?.playSetting ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
updateSpecFormValue('playSetting', event.target.value)
}
/>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label="美术风格"
value={generateDialog.specValues?.artStyle ?? ''}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
updateSpecFormValue('artStyle', event.target.value)
}
/>
</label>
{generateDialog.specType === 'character' ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformSelectField
aria-label="头身比"
value={generateDialog.specValues?.bodyRatio ?? '3'}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
updateSpecFormValue(
'bodyRatio',
event.target.value,
)
}
>
{['2', '3', '4', '5', '6'].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformSelectField
aria-label="角色视角"
value={
generateDialog.specValues?.characterView ?? ''
}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
updateSpecFormValue(
'characterView',
event.target.value,
)
}
>
{CHARACTER_SPEC_VIEW_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
</>
) : null}
</>
)}
</div>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__spec-footer">
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__spec-submit"
disabled={generateDialog.status === 'generating'}
aria-label="提交生成规范"
>
{generateDialog.status === 'generating'
? '生成中'
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
</PlatformActionButton>
</div>
</form>
) : null}
{generateDialog?.mode === 'character' && generationComposerStyle ? (
<form
className="image-canvas-editor__character-composer"
style={generationComposerStyle}
role="dialog"
aria-label="生成角色形象"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__character-reference-row">
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<span className="image-canvas-editor__character-spec-wrap">
<button
ref={characterSpecButtonRef}
type="button"
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
disabled={generateDialog.status === 'generating'}
onClick={() =>
setIsCharacterSpecMenuOpen((open) => !open)
}
>
<span className="image-canvas-editor__reference-tile-visual">
{generateDialog.characterSpecReference ? (
<img
src={generateDialog.characterSpecReference.src}
alt=""
aria-hidden="true"
/>
) : (
<ClipboardList
className="h-4 w-4"
aria-hidden="true"
/>
)}
</span>
<span className="image-canvas-editor__reference-tile-copy">
{generateDialog.characterSpecReference?.label ??
'角色形象规范'}
</span>
</button>
</span>
</div>
{isCharacterSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="角色形象规范来源"
placement="bottom-start"
style={buildPortalMenuStyle(
characterSpecButtonRef.current,
'below',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingCharacterSpecFromCanvas(true);
setIsCharacterSpecMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsCharacterSpecMenuOpen(false);
openSpecDialog('character');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setUploadTarget('character-spec');
setIsCharacterSpecMenuOpen(false);
uploadInputRef.current?.click();
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__character-reference-list">
{(generateDialog.characterReferences ?? []).map(
(reference, index) => (
<span
key={reference.id}
className="image-canvas-editor__character-ref-thumb"
title={reference.label}
>
<img src={reference.src} alt={reference.label} />
<span className="image-canvas-editor__character-ref-index">
{index + 1}
</span>
</span>
),
)}
<button
type="button"
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setUploadTarget('character-reference');
uploadInputRef.current?.click();
}}
>
<span className="image-canvas-editor__reference-tile-visual">
<ImagePlus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-tile-copy">
</span>
</button>
</div>
</div>
</div>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="角色设定"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...currentDialog,
prompt: event.target.value,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
)
}
/>
</label>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-field">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('角色比例')}
>
1:1
</PlatformInlineOptionButton>
</div>
<div className="image-canvas-editor__option-field">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('角色模型')}
>
GPT Image
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
>
{generateDialog.status === 'generating' ? '生成中' : '生成'}
</PlatformActionButton>
</div>
</form>
) : null}
{generateDialog?.mode === 'icon' &&
generateDialog.composerOpen !== false &&
iconComposerStyle ? (
<form
className="image-canvas-editor__icon-composer"
style={iconComposerStyle}
role="dialog"
aria-label="生成图标素材"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitIconSpritesheetGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__icon-spec-row">
<span className="image-canvas-editor__character-spec-wrap">
<button
ref={iconSpecButtonRef}
type="button"
className="image-canvas-editor__icon-spec-card"
disabled={generateDialog.status === 'generating'}
aria-label={
generateDialog.iconSpecReference?.label ??
'图标素材规范'
}
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
>
<span
className="image-canvas-editor__icon-spec-preview"
aria-hidden="true"
>
{generateDialog.iconSpecReference?.src ? (
<img
src={generateDialog.iconSpecReference.src}
alt=""
/>
) : (
<ImageIcon className="h-5 w-5" />
)}
</span>
<span className="image-canvas-editor__icon-spec-copy">
<span className="image-canvas-editor__icon-spec-eyebrow">
</span>
<span className="image-canvas-editor__icon-spec-title">
{generateDialog.iconSpecReference?.label ?? '待选择'}
</span>
</span>
<span className="image-canvas-editor__icon-spec-state">
{generateDialog.iconSpecReference ? '已绑定' : '待绑定'}
</span>
</button>
</span>
{isIconSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标素材规范来源"
placement="bottom-start"
style={buildPortalMenuStyle(
iconSpecButtonRef.current,
'below',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingIconSpecFromCanvas(true);
setIsIconSpecMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsIconSpecMenuOpen(false);
openSpecDialog('icon');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setUploadTarget('icon-spec');
setIsIconSpecMenuOpen(false);
uploadInputRef.current?.click();
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
<div
className="image-canvas-editor__icon-spec-actions"
aria-label="图标素材规范操作"
>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setIsPickingIconSpecFromCanvas(true);
setIsIconSpecMenuOpen(false);
}}
>
</button>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setIsIconSpecMenuOpen(false);
openSpecDialog('icon');
}}
>
</button>
<button
type="button"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setUploadTarget('icon-spec');
setIsIconSpecMenuOpen(false);
uploadInputRef.current?.click();
}}
>
</button>
</div>
</div>
</div>
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__icon-description-list">
{iconDescriptionValues.map((description, index) => (
<label
key={index}
className="image-canvas-editor__icon-description-card"
>
<span className="image-canvas-editor__icon-description-index">
{String(index + 1).padStart(2, '0')}
</span>
<span className="image-canvas-editor__icon-description-title">
{index + 1}
</span>
<PlatformTextField
aria-label={`素材描述${index + 1}`}
value={description}
disabled={generateDialog.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__icon-description-input"
onChange={(event) =>
updateIconDescription(index, event.target.value)
}
/>
</label>
))}
</div>
</div>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__icon-footer">
<button
type="button"
className="image-canvas-editor__character-reference-add"
disabled={
generateDialog.status === 'generating' ||
(
generateDialog.iconDescriptions ??
DEFAULT_ICON_DESCRIPTIONS
).length >= ICON_DESCRIPTION_LIMIT
}
onClick={addIconDescription}
>
</button>
<div className="image-canvas-editor__option-field">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('图标模型')}
>
nanobanana2
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
aria-label="生成"
>
{generateDialog.status === 'generating' ? '生成中' : '生成'}
</PlatformActionButton>
</div>
</form>
) : null}
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{quickEditPanel &&
quickEditPanel.status !== 'generating' &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
<form
className="image-canvas-editor__quick-edit-panel"
style={quickEditPanelStyle}
role="dialog"
aria-label="快速编辑图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
void submitQuickEdit();
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img
src={quickEditSourceLayer.src}
alt={`${quickEditSourceLayer.title}参考图`}
/>
<span>{quickEditSourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭快速编辑图片"
title="关闭"
icon={X}
onClick={() => setQuickEditPanel(null)}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label="快速编辑提示词"
value={quickEditPanel.prompt}
size="sm"
density="compact"
className="image-canvas-editor__quick-edit-prompt"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
prompt: event.target.value,
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
)
}
/>
<div className="image-canvas-editor__quick-edit-controls">
<PlatformSelectField
aria-label="快速编辑尺寸"
value={quickEditPanel.size}
size="xs"
density="compact"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? { ...currentPanel, size: event.target.value }
: currentPanel,
)
}
>
{quickEditSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</PlatformSelectField>
<PlatformSelectField
aria-label="快速编辑模型"
value={quickEditPanel.model}
size="xs"
density="compact"
onChange={(event) =>
setQuickEditPanel((currentPanel) =>
currentPanel
? { ...currentPanel, model: event.target.value }
: currentPanel,
)
}
>
{quickEditModelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</PlatformSelectField>
</div>
{quickEditPanel.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
role="alert"
>
{quickEditPanel.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
>
</PlatformActionButton>
</form>
) : null}
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
<div
className="image-canvas-editor__context-menu"
style={{
left: imageContextMenu.x,
top: imageContextMenu.y,
}}
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformFloatingMenu
label="图片功能面板"
placement="bottom-start"
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => openQuickEditPanel(imageContextMenuLayer)}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setMetadataLayer(imageContextMenuLayer);
setImageContextMenu(null);
}}
>
</PlatformFloatingMenuItem>
{imageContextMenuLayer.assetKind === 'character' ? (
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
openCharacterAnimationPanel(imageContextMenuLayer)
}
>
</PlatformFloatingMenuItem>
) : null}
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => deleteLayerById(imageContextMenuLayer.id)}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
</div>
) : null}
{characterAnimationPanel &&
characterAnimationSourceLayer &&
characterAnimationPanelStyle ? (
<form
className="image-canvas-editor__character-animation-panel"
style={characterAnimationPanelStyle}
role="dialog"
aria-label="角色动画生成面板"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (characterAnimationPanel.status !== 'generating') {
void submitCharacterAnimation();
}
}}
>
<div className="image-canvas-editor__character-animation-head">
<strong></strong>
<EditorIconButton
label="关闭角色动画生成面板"
title="关闭"
icon={X}
onClick={() => setCharacterAnimationPanel(null)}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label="动画描述"
value={characterAnimationPanel.promptText}
maxLength={4000}
disabled={characterAnimationPanel.status === 'generating'}
size="sm"
density="compact"
className="image-canvas-editor__character-animation-textarea"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
promptText: event.target.value.slice(0, 4000),
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
)
}
/>
<div className="image-canvas-editor__character-animation-presets">
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
<button
key={preset.label}
type="button"
className="image-canvas-editor__character-animation-preset"
disabled={characterAnimationPanel.status === 'generating'}
onClick={() =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
promptText: preset.text,
status: 'idle',
errorMessage: undefined,
}
: currentPanel,
)
}
>
{preset.label}
</button>
))}
</div>
<div className="image-canvas-editor__character-animation-grid">
<PlatformSelectField
aria-label="分辨率"
value={characterAnimationPanel.resolution}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
resolution:
event.target.value === '720p' ? '720p' : '480p',
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
)
}
>
<option value="480p">480p</option>
<option value="720p">720p</option>
</PlatformSelectField>
<PlatformSelectField
aria-label="画面比例"
value={characterAnimationPanel.ratio}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
ratio:
CHARACTER_ANIMATION_RATIO_OPTIONS.find(
(item) => item.value === event.target.value,
)?.value ?? 'same',
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
)
}
>
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</PlatformSelectField>
<PlatformSelectField
aria-label="时长"
value={String(characterAnimationPanel.frameCount)}
disabled={characterAnimationPanel.status === 'generating'}
size="xs"
density="compact"
onChange={(event) =>
updateCharacterAnimationDuration(event.target.value)
}
>
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
<option
key={option.frameCount}
value={String(option.frameCount)}
>
{option.label}
</option>
))}
</PlatformSelectField>
</div>
<div className="image-canvas-editor__character-animation-summary">
<span
className="image-canvas-editor__character-animation-summary-text"
title={characterAnimationPanel.promptText.trim() || undefined}
aria-label={`生成文本:${
characterAnimationPanel.promptText.trim() || '动画描述'
}`}
>
{characterAnimationPanel.promptText.trim()
? characterAnimationPanel.promptText.trim()
: '动画描述'}
</span>
<strong>{characterAnimationPrice}</strong>
</div>
{characterAnimationPanel.status === 'completed' &&
characterAnimationPanel.result ? (
<PlatformStatusMessage
tone="success"
surface="platform"
size="xs"
role="status"
>
{characterAnimationPanel.result.frameCount}
</PlatformStatusMessage>
) : null}
{characterAnimationPanel.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
role="alert"
>
{characterAnimationPanel.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__character-animation-submit"
disabled={characterAnimationPanel.status === 'generating'}
>
{characterAnimationPanel.status === 'generating'
? '生成中'
: '生成'}
</PlatformActionButton>
</form>
) : null}
</div>
</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>
<UnifiedModal
open={
generateDialog?.mode === 'edit' &&
generateDialog.status !== 'generating'
}
title={generateDialog?.mode === 'edit' ? '修改图片' : '生成图片'}
size="sm"
closeLabel={
generateDialog?.mode === 'edit' ? '关闭修改图片' : '关闭生成图片'
}
closeDisabled={generateDialog?.status === 'generating'}
onClose={() => setGenerateDialog(null)}
panelClassName="image-canvas-editor__generate-dialog"
bodyClassName="image-canvas-editor__generate-dialog-body"
>
{generateDialog?.mode === 'edit' ? (
<form
className="image-canvas-editor__generate-form"
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__generate-body">
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
size="sm"
density="roomy"
className="image-canvas-editor__generate-prompt"
placeholder={
generateDialog.mode === 'edit'
? '描述你想如何修改这张图片'
: '描述你想生成的图片'
}
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
}
: currentDialog,
)
}
/>
{generateDialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
{generateDialog.mode === 'edit' ? '修改中' : '生成中'}
</PlatformStatusMessage>
) : null}
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{generateDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
size="sm"
className="image-canvas-editor__generate-submit"
disabled={generateDialog.status === 'generating'}
>
{generateDialog.status === 'generating'
? generateDialog.mode === 'edit'
? '修改中'
: '生成中'
: generateDialog.mode === 'edit'
? '修改'
: '生成'}
</PlatformActionButton>
</div>
</form>
) : null}
</UnifiedModal>
</section>
);
}
export default ImageCanvasEditorView;