import { Braces, Check, CheckSquare, ChevronDown, ChevronRight, ClipboardList, Copy, Crop, Download, Folder, FolderPlus, Hand, ImageIcon, ImagePlus, Info, Layers, Map as MapIcon, MousePointer2, Pencil, PencilLine, RotateCcw, Shapes, SlidersHorizontal, Sparkles, Square, Trash2, Type, WandSparkles, X, } from 'lucide-react'; 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 EditorAssetLibrarySnapshot, type EditorCharacterAnimationFrameCount, type EditorCharacterAnimationGenerationResult, type EditorCharacterAnimationRatio, type EditorCharacterAnimationResolution, type EditorIconSpritesheetGenerationResult, type EditorIconSpritesheetIconResult, type EditorImageGenerationResult, type EditorProjectLayerSnapshot, generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; 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 { EditorIconButton, SidebarMediaItem, } from './ImageCanvasEditorPrimitives'; type EditorAsset = { id: string; label: string; src: string; width: number; height: number; folderId: string; sourceKind: 'built-in' | 'uploaded'; sourceType: CanvasLayer['sourceType']; persisted: boolean; prompt?: string; actualPrompt?: string; model?: string; provider?: string; taskId?: string; objectKey?: string; assetObjectId?: string; }; type CanvasLayer = { id: string; resourceId: string; title: string; src: string; x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; zIndex: number; sourceType: 'uploaded' | 'generated' | 'mock_generated'; prompt?: string | null; actualPrompt?: string | null; model?: string | null; provider?: string | null; taskId?: string | null; objectKey?: string | null; assetObjectId?: string | null; sourceResourceId?: string | null; groupId?: string | null; assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null; }; type CanvasViewport = { x: number; y: number; scale: number; }; type CanvasTool = | 'select' | 'hand' | 'upload' | 'generate' | 'spec' | 'character' | 'icon' | 'text' | 'shape' | 'export'; type SidebarPanel = 'assets' | 'layers'; type EditorAssetFolder = { id: string; label: string; collapsed: boolean; systemDefault: boolean; persisted: boolean; }; type GenerateDialogState = { id?: string; mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon'; prompt: string; status: 'idle' | 'generating' | 'failed'; composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; specType?: SpecGenerationType; specValues?: SpecFormValues; characterSpecReference?: CharacterReferenceImage | null; characterReferences?: CharacterReferenceImage[]; iconSpecReference?: CharacterReferenceImage | null; iconDescriptions?: string[]; errorMessage?: string; placeholder?: { x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; }; }; type CanvasGenerationDialogMode = Exclude; type CanvasGenerationDialogState = GenerateDialogState & { id: string; mode: CanvasGenerationDialogMode; }; type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom'; type SpecFormValues = { playSetting: string; artStyle: string; bodyRatio: string; characterView: string; customPrompt: string; }; type CharacterReferenceImage = { id: string; label: string; src: string; }; type ImageContextMenuState = { layerId: string; x: number; y: number; }; type QuickEditPanelState = { sourceLayerId: string; prompt: string; size: string; model: string; status: 'idle' | 'generating' | 'failed'; errorMessage?: string; }; type CharacterAnimationPanelState = { sourceLayerId: string; promptText: string; resolution: EditorCharacterAnimationResolution; ratio: EditorCharacterAnimationRatio; frameCount: EditorCharacterAnimationFrameCount; durationSeconds: 4 | 5 | 6; status: 'idle' | 'generating' | 'completed' | 'failed'; errorMessage?: string; result?: EditorCharacterAnimationGenerationResult; }; type UploadTarget = | 'asset' | 'character-spec' | 'character-reference' | 'icon-spec'; type SnapGuide = { vertical?: number; horizontal?: number; }; type SnapCandidate = { position: number; guide: number; distance: number; }; type AssetMarqueeState = { pointerId: number; startX: number; startY: number; currentX: number; currentY: number; }; type CanvasMarqueeState = { pointerId: number; startX: number; startY: number; currentX: number; currentY: number; }; type DragState = | { kind: 'pan'; pointerId: number; startClientX: number; startClientY: number; startViewport: CanvasViewport; } | { kind: 'layer'; pointerId: number; layerId: string; layerIds: string[]; startClientX: number; startClientY: number; startLayerX: number; startLayerY: number; startLayers: Array<{ id: string; x: number; y: number }>; startScale: number; } | { kind: 'generation-frame'; dialogId: string; pointerId: number; startClientX: number; startClientY: number; startFrameX: number; startFrameY: number; startScale: number; } | { kind: 'minimap'; pointerId: number; startClientX: number; startClientY: number; startViewport: CanvasViewport; minimapScale: number; moved: boolean; }; const EDITOR_ASSETS: EditorAsset[] = [ { id: 'puzzle', label: '拼图素材', src: '/creation-type-references/puzzle.webp', width: 640, height: 640, folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'match3d', label: '抓大鹅素材', src: '/creation-type-references/match3d.webp', width: 640, height: 640, folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'big-fish', label: '大鱼素材', src: '/creation-type-references/big-fish.webp', width: 720, height: 405, folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'bark-battle', label: '声浪素材', src: '/creation-type-references/bark-battle.webp', width: 640, height: 900, folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'visual-novel', label: '视觉小说素材', src: '/creation-type-references/visual-novel.webp', width: 720, height: 405, folderId: 'project', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, ]; const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: false, }, ]; const INITIAL_LAYERS: CanvasLayer[] = [ { id: 'layer-puzzle', resourceId: 'resource-puzzle', title: '拼图素材', src: '/creation-type-references/puzzle.webp', x: 470, y: 300, width: 420, height: 420, originalWidth: 640, originalHeight: 640, zIndex: 1, sourceType: 'uploaded', }, { id: 'layer-big-fish', resourceId: 'resource-big-fish', title: '大鱼素材', src: '/creation-type-references/big-fish.webp', x: 930, y: 360, width: 420, height: 236, originalWidth: 720, originalHeight: 405, zIndex: 2, sourceType: 'uploaded', }, ]; const CANVAS_WORLD_SIZE = 12000; const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; const MIN_SCALE = 0.24; const MAX_SCALE = 3.2; const TOOLBAR_HALF_WIDTH = 132; const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; const SNAP_THRESHOLD_SCREEN_PX = 18; const FIT_VIEW_PADDING = 10; const MINIMAP_SIZE = { width: 132, height: 84 }; const MINIMAP_PADDING = 8; const MINIMAP_DRAG_SENSITIVITY = 0.3; const SPEC_GENERATION_COST = 5; const SPEC_GENERATION_SIZE = '2048x1152'; const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; const DEFAULT_IMAGE_MODEL = 'gpt-image-2'; const ICON_DESCRIPTION_LIMIT = 100; // 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。 const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4; const ICON_COMPOSER_MIN_WIDTH_REM = 28; const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4; const DEFAULT_ICON_DESCRIPTIONS = [ '返回按钮', '设置按钮', '下一关按钮', '提示按钮', '原图按钮', '冻结按钮', ]; const QUICK_EDIT_SIZE_PRESETS = [ '1024x1024', '1536x1024', '2048x1152', '1024x1536', ] as const; const QUICK_EDIT_MODEL_OPTIONS = [ { label: 'GPT Image', value: DEFAULT_IMAGE_MODEL }, ] as const; const CHARACTER_ANIMATION_MODEL = 'seedance2.0'; const CHARACTER_ANIMATION_ACTION_PROMPTS = [ { label: '待机', text: '待机动作,轻微呼吸起伏。' }, { label: '行走', text: '循环行走动作,步伐稳定。' }, { label: '奔跑', text: '循环奔跑动作,动作清晰有力。' }, { label: '跳跃', text: '起跳、滞空、落地动作。' }, { label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' }, { label: '受击', text: '受击后短暂后仰并恢复站姿。' }, { label: '倒下', text: '倒下动作,重心下落自然。' }, ] as const; const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{ label: string; value: EditorCharacterAnimationRatio; }> = [ { label: '与角色图片保持同尺寸', value: 'same' }, { label: '1:1', value: '1:1' }, { label: '4:3', value: '4:3' }, { label: '16:9', value: '16:9' }, { label: '9:16', value: '9:16' }, { label: '3:4', value: '3:4' }, ]; const CHARACTER_ANIMATION_DURATION_OPTIONS = [ { label: '32帧·4秒', frameCount: 32, durationSeconds: 4 }, { label: '40帧·5秒', frameCount: 40, durationSeconds: 5 }, { label: '48帧·6秒', frameCount: 48, durationSeconds: 6 }, ] as const; const CANVAS_BACKGROUND_OPTIONS = [ { label: '白色', value: '#ffffff' }, { label: '浅灰', value: '#f8fafc' }, { label: '暖灰', value: '#f3f0ea' }, { label: '冷蓝', value: '#eef6ff' }, ]; const DEFAULT_SPEC_FORM_VALUES: Record = { character: { playSetting: '战棋类RPG玩法', artStyle: '像素风', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, ui: { playSetting: '抓娃娃题材的抓大鹅玩法', artStyle: '毛茸茸', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, icon: { playSetting: '休闲小游戏', artStyle: '清爽卡通', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, custom: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, }; const SPEC_TYPE_LABEL: Record = { character: '角色形象规范', ui: 'UI素材规范', icon: '图标素材规范', custom: '自定义规范', }; const CHARACTER_SPEC_VIEW_OPTIONS = [ DEFAULT_SPEC_FORM_VALUES.character.characterView, '左向三分之二侧身站姿', '左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。', '右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。', '背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。', ]; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } function formatImageSizeValue(width: number, height: number) { const safeWidth = Math.max(1, Math.round(width || 1024)); const safeHeight = Math.max(1, Math.round(height || 1024)); return `${safeWidth}x${safeHeight}`; } function buildQuickEditSizeOptions(currentSize: string) { return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS])); } function buildQuickEditModelOptions(currentModel: string) { const options = [...QUICK_EDIT_MODEL_OPTIONS]; return options.some((option) => option.value === currentModel) ? options : [{ label: currentModel, value: currentModel }, ...options]; } function triggerPlaceholderAction(label: string) { window.alert(`${label}功能建设中`); } function createLayerFromAsset( asset: EditorAsset, index: number, viewport: CanvasViewport, screenCenter: { x: number; y: number }, ): CanvasLayer { const longestSide = Math.max(asset.width, asset.height); const sizeRatio = longestSide > 0 ? 360 / longestSide : 1; const width = Math.round(asset.width * sizeRatio); const height = Math.round(asset.height * sizeRatio); const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale; const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale; const offset = index * 34; return { id: `layer-${asset.id}-${index}`, resourceId: `local-resource-${asset.id}-${index}`, title: asset.label, src: asset.src, x: worldCenterX - width / 2 + offset, y: worldCenterY - height / 2 + offset, width, height, originalWidth: asset.width, originalHeight: asset.height, zIndex: index + 10, sourceType: asset.sourceType, prompt: asset.prompt, actualPrompt: asset.actualPrompt, model: asset.model, provider: asset.provider, taskId: asset.taskId, objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, } satisfies CanvasLayer; } function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { return { layerId: layer.id, resourceId: layer.resourceId, title: layer.title, x: layer.x, y: layer.y, width: layer.width, height: layer.height, originalWidth: layer.originalWidth, originalHeight: layer.originalHeight, zIndex: layer.zIndex, sourceType: layer.sourceType, prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, provider: layer.provider, taskId: layer.taskId, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, sourceResourceId: layer.sourceResourceId, groupId: layer.groupId, assetKind: layer.assetKind, }; } function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, ): CanvasLayer | null { const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } return { id: layerId, resourceId, title, src, x: numberFromSnapshot(snapshot.x, 0), y: numberFromSnapshot(snapshot.y, 0), width: numberFromSnapshot(snapshot.width, 320), height: numberFromSnapshot(snapshot.height, 320), originalWidth: numberFromSnapshot(snapshot.originalWidth, 320), originalHeight: numberFromSnapshot(snapshot.originalHeight, 320), zIndex: numberFromSnapshot(snapshot.zIndex, 1), sourceType: isCanvasSourceType(snapshot.sourceType) ? snapshot.sourceType : 'uploaded', prompt: stringOrNull(snapshot.prompt), actualPrompt: stringOrNull(snapshot.actualPrompt), model: stringOrNull(snapshot.model), provider: stringOrNull(snapshot.provider), taskId: stringOrNull(snapshot.taskId), objectKey: stringOrNull(snapshot.objectKey), assetObjectId: stringOrNull(snapshot.assetObjectId), sourceResourceId: stringOrNull(snapshot.sourceResourceId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(snapshot.assetKind), }; } function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { folders: EditorAssetFolder[]; assets: EditorAsset[]; } { return { folders: library.folders.map((folder) => ({ id: folder.folderId, label: folder.label, collapsed: folder.collapsed, systemDefault: folder.systemDefault, persisted: true, })), assets: library.assets.map((asset) => ({ id: asset.assetId, label: asset.label, src: asset.imageSrc, width: asset.width, height: asset.height, folderId: asset.folderId, sourceKind: 'uploaded', sourceType: asset.sourceType, persisted: true, prompt: asset.prompt ?? undefined, actualPrompt: asset.actualPrompt ?? undefined, model: asset.model ?? undefined, provider: asset.provider ?? undefined, taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, })), }; } function mergeAssetLibraryWithBuiltIns(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); const persistedFolderIds = new Set(mapped.folders.map((folder) => folder.id)); const builtInFolders = EDITOR_ASSET_FOLDERS.filter( (folder) => !persistedFolderIds.has(folder.id), ); return { folders: [...mapped.folders, ...builtInFolders], assets: [...EDITOR_ASSETS, ...mapped.assets], }; } function numberFromSnapshot(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] { return value === 'spec' || value === 'character' || value === 'icon' || value === 'icon-spec' ? value : null; } function isCanvasSourceType( value: unknown, ): value is CanvasLayer['sourceType'] { return ( value === 'uploaded' || value === 'generated' || value === 'mock_generated' ); } function isGeneratedLayer(layer: CanvasLayer) { return ( layer.sourceType === 'generated' || layer.sourceType === 'mock_generated' ); } function buildCharacterSpecPrompt(values: SpecFormValues) { return [ '生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿', '禁止模糊、笔触杂乱、光影方向混乱、比例畸形、3D 渲染、实景照片、水印、花纹堆砌、画面抖动错位效果、噪点,', `玩法设计:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.character.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.character.artStyle}`, `头身比:${values.bodyRatio.trim() || DEFAULT_SPEC_FORM_VALUES.character.bodyRatio}`, `视角要求:${values.characterView.trim() || DEFAULT_SPEC_FORM_VALUES.character.characterView}`, ].join('\n'); } function buildUiSpecPrompt(values: SpecFormValues) { return [ '生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉', '', `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`, ].join('\n'); } function buildIconSpecPrompt(values: SpecFormValues) { return [ '生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。', '', `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`, ].join('\n'); } function buildSpecPrompt(type: SpecGenerationType, values: SpecFormValues) { if (type === 'character') { return buildCharacterSpecPrompt(values); } if (type === 'ui') { return buildUiSpecPrompt(values); } if (type === 'icon') { return buildIconSpecPrompt(values); } return values.customPrompt.trim(); } function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'spec') { return '规范'; } if (layer.assetKind === 'character') { return '角色'; } if (layer.assetKind === 'icon') { return '图标'; } if (layer.assetKind === 'icon-spec') { return '图标规范'; } return null; } function formatLayerImageType(layer: CanvasLayer) { if (layer.assetKind === 'spec') { return '规范图片'; } if (layer.assetKind === 'character') { return '角色图片'; } if (layer.assetKind === 'icon') { return '图标素材图片'; } if (layer.assetKind === 'icon-spec') { return '图标素材规范图片'; } return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; } function calculateCharacterAnimationPrice( resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { return (resolution === '720p' ? 20 : 10) * durationSeconds; } function createCanvasLayerReference( layer: CanvasLayer, ): CharacterReferenceImage { return { id: `canvas-${layer.id}`, label: layer.title, src: layer.src, }; } 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 getLayerBounds(targetLayers: CanvasLayer[]) { if (targetLayers.length === 0) { return null; } return targetLayers.reduce( (current, layer) => ({ minX: Math.min(current.minX, layer.x), minY: Math.min(current.minY, layer.y), maxX: Math.max(current.maxX, layer.x + layer.width), maxY: Math.max(current.maxY, layer.y + layer.height), }), { minX: Number.POSITIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY, }, ); } 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) { 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) { 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) { const nativeId = (event.nativeEvent as PointerEvent).pointerId; if (Number.isFinite(event.pointerId)) { return event.pointerId; } return Number.isFinite(nativeId) ? nativeId : -1; } function isCanvasGenerationDialog( dialog: GenerateDialogState | null, ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && (dialog.mode === 'generate' || dialog.mode === 'spec' || dialog.mode === 'character' || dialog.mode === 'icon'), ); } function getGenerationFrameAriaLabel(dialog: CanvasGenerationDialogState) { if (dialog.mode === 'character') { return '角色生成占位图'; } if (dialog.mode === 'spec') { return '规范生成占位图'; } if (dialog.mode === 'icon') { return '图标素材生成占位图'; } return '图像生成占位图'; } function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { if (dialog.mode === 'character') { return 'Character Generator'; } if (dialog.mode === 'spec') { return 'Spec Generator'; } if (dialog.mode === 'icon') { return 'Icon Generator'; } return 'Image Generator'; } function resolveImageGenerationErrorMessage(error: unknown) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ) { return '请先登录后再生成图片'; } return error instanceof Error && error.message.trim() ? error.message : '生成图片失败'; } export function ImageCanvasEditorView() { const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(INITIAL_LAYERS.length); const generationDialogCounterRef = useRef(0); const saveTimerRef = useRef(null); const projectIdRef = useRef(null); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const pendingProjectResourceLayersRef = useRef< Array<{ layer: CanvasLayer; options: { onCreated?: (resourceId: string) => void }; }> >([]); const selectedLayerIdRef = useRef(null); const generateDialogRef = useRef(null); const inactiveGenerateDialogsRef = useRef([]); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [viewport, setViewport] = useState({ x: -260, y: 70, scale: 0.82, }); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const [assetFolders, setAssetFolders] = useState(EDITOR_ASSET_FOLDERS); const [assets, setAssets] = useState(EDITOR_ASSETS); const [layers, setLayers] = useState(INITIAL_LAYERS); 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>( () => new Set(), ); const [assetMarquee, setAssetMarquee] = useState( null, ); const [canvasMarquee, setCanvasMarquee] = useState( null, ); const [selectedLayerId, setSelectedLayerId] = useState( INITIAL_LAYERS[0]?.id ?? null, ); const [selectedLayerIds, setSelectedLayerIds] = useState( INITIAL_LAYERS[0]?.id ? [INITIAL_LAYERS[0].id] : [], ); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [activeTool, setActiveTool] = useState('select'); const [isSpacePanning, setIsSpacePanning] = useState(false); const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); const [isBackgroundMenuOpen, setIsBackgroundMenuOpen] = useState(false); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( CANVAS_BACKGROUND_OPTIONS[1]?.value ?? '#f8fafc', ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = useState(null); const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< CanvasGenerationDialogState[] >([]); const [uploadTarget, setUploadTarget] = useState('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(null); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); selectedLayerIdRef.current = selectedLayerId; generateDialogRef.current = generateDialog; inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; 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 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 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, ) => { 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 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); }, [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(() => {}); }, [], ); 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 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(() => { if (!cancelled) { setIsProjectReady(false); } }); return () => { cancelled = true; }; }, [createProjectResourceForLayer, selectSingleLayer]); useEffect(() => { let cancelled = false; loadEditorAssetLibrary() .then((library) => { if (cancelled) { return; } const nextLibrary = mergeAssetLibraryWithBuiltIns(library); setAssetFolders(nextLibrary.folders); setAssets(nextLibrary.assets); const defaultFolder = nextLibrary.folders.find( (folder) => folder.systemDefault, ); setActiveUploadFolderId( defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', ); }) .catch(() => {}); return () => { cancelled = true; }; }, []); 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.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); setIsBackgroundMenuOpen(false); setIsSpecMenuOpen(false); setImageContextMenu(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); }; }, []); 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(() => { if (!projectId || !isProjectReady) { return undefined; } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { saveEditorProjectLayout(projectId, { viewport, layers: layers.map(serializeLayer), }).catch(() => {}); }, 450); return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); } }; }, [isProjectReady, layers, 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, ); setViewport({ x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, scale, }); }, [canvasSize.height, canvasSize.width, layers], ); const updateScaleFromCenter = (nextScale: number) => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { 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; 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 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, }, ); setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setHoveredLayerId(null); createProjectResourceForLayer(nextLayer); }; 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), ); 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 = mergeAssetLibraryWithBuiltIns(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]; setAssets((currentAssets) => currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), ); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { void deleteEditorAsset(assetId); }); }; const closeAssetSelectionMode = () => { setIsAssetSelectionMode(false); setSelectedAssetIds(new Set()); setAssetMarquee(null); }; const updateAssetSelectionFromMarquee = (selectionRect: { left: number; right: number; top: number; bottom: number; }) => { const nextSelectedIds = new Set(); assetListRef.current ?.querySelectorAll('[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, ) => { 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, ) => { 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, ) => { 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((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 uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1; layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex); const imageSrc = await readImageFileAsDataUrl(file); const fallbackWidth = 420; const fallbackHeight = 315; const uploadFolderId = assetFolders.some( (folder) => folder.id === (options.folderId ?? activeUploadFolderId), ) ? (options.folderId ?? activeUploadFolderId) : 'project'; 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', }; const uploadedAsset: EditorAsset = { id: `upload-${uploadIndex}`, label: file.name || '上传图片', src: imageSrc, width: fallbackWidth, height: fallbackHeight, folderId: uploadFolderId, sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, }; if (options.addToCanvas) { setLayers((currentLayers) => [...currentLayers, nextLayer]); } setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => currentFolders.map((folder) => folder.id === uploadFolderId ? { ...folder, collapsed: false, } : folder, ), ); if (options.addToCanvas) { selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); } 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, } : currentAsset, ), ); }) .catch(() => {}); if (options.addToCanvas) { createProjectResourceForLayer(nextLayer); } if (imageSrc) { const uploadedImage = new Image(); uploadedImage.onload = () => { const originalWidth = uploadedImage.naturalWidth || fallbackWidth; const originalHeight = uploadedImage.naturalHeight || fallbackHeight; const longestSide = Math.max(originalWidth, originalHeight); const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; const width = Math.round(originalWidth * sizeRatio); const height = Math.round(originalHeight * sizeRatio); 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; } = {}, ) => { Array.from(files).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); 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 = () => deleteLayerById(selectedLayerId); 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; } = {}, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const originalWidth = generated.width || 1024; const originalHeight = generated.height || 1024; const longestSide = Math.max(originalWidth, originalHeight); const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio); const height = options.frame?.height ?? Math.round(originalHeight * sizeRatio); const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; 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 : (options.frame?.x ?? worldCenterX - width / 2), y: options.sourceLayer ? options.sourceLayer.y : (options.frame?.y ?? 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, }; 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, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; 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: sourceLayer.width, height: sourceLayer.height, originalWidth: sourceLayer.originalWidth, originalHeight: sourceLayer.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, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); setQuickEditPanel(null); setActiveTool('select'); fitLayers([sourceLayer, nextLayer]); createProjectResourceForLayer(nextLayer); }; const addIconSpritesheetResultLayers = ( generated: EditorIconSpritesheetGenerationResult, iconResults: EditorIconSpritesheetIconResult[], 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 longestSide = Math.max(originalWidth, originalHeight); const sizeRatio = longestSide > 0 ? Math.min(1, 128 / longestSide) : 1; const width = Math.round(originalWidth * sizeRatio); const height = Math.round(originalHeight * sizeRatio); 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', }); 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, 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); } 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 }); } 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, }); } 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, }); } else { const generated = await generateEditorImage({ prompt: normalizedPrompt, }); addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), dialogId: canvasDialog?.id, }); } } 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) => { 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) => { 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, ) => { 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) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }; const handleCanvasDrop = (event: ReactDragEvent) => { const files = event.dataTransfer.files; if (!files.length) { return; } event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); const canvasPoint = rect ? { x: event.clientX - rect.left, y: event.clientY - rect.top, } : { x: canvasSize.width / 2, y: canvasSize.height / 2, }; const defaultFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; addUploadedFiles(files, { folderId: defaultFolder?.id, canvasPoint, addToCanvas: true, }); }; const handleLayerPointerDown = ( event: ReactPointerEvent, layer: CanvasLayer, ) => { const button = getPointerButton(event); if (button === 1 || effectiveTool === 'hand') { event.stopPropagation(); startPan(event as unknown as ReactPointerEvent); 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, dialog: CanvasGenerationDialogState, ) => { if (!dialog.placeholder) { return; } const button = getPointerButton(event); if (button === 1 || effectiveTool === 'hand') { event.stopPropagation(); startPan(event as unknown as ReactPointerEvent); 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, 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, ) => { 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) => { 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) => { 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()}`; 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: characterAnimationSourceLayer.src, 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 (
event.preventDefault()} > { 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 = ''; }} /> {activeSidebarPanel ? ( ) : null}

图片编辑器

画布
{snapGuide?.vertical !== undefined ? (
) : null} {snapGuide?.horizontal !== undefined ? (
) : null} {layers .slice() .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 ( ); })} {canvasMarquee ? ( {selectedLayer && selectedToolbarStyle ? (
event.stopPropagation()} > {toolButtons.map(({ label, icon: Icon }) => ( triggerPlaceholderAction(label)} /> ))} openQuickEditPanel(selectedLayer)} /> {isGeneratedLayer(selectedLayer) ? ( <> setMetadataLayer(selectedLayer)} /> openEditDialog(selectedLayer)} /> ) : null} {selectedLayer.assetKind === 'character' ? ( openCharacterAnimationPanel(selectedLayer)} /> ) : null}
) : null} fitLayers()} />
event.stopPropagation()} >
setIsZoomMenuOpen((open) => !open)} > {formatPercent(viewport.scale)} {isZoomMenuOpen ? ( { updateScaleFromCenter(viewport.scale * 1.16); setIsZoomMenuOpen(false); }} > 放大 { updateScaleFromCenter(viewport.scale * 0.86); setIsZoomMenuOpen(false); }} > 缩小 { fitLayers(); setIsZoomMenuOpen(false); }} > 显示画布所有元素 {[0.5, 1, 2].map((scale) => ( { updateScaleFromCenter(scale); setIsZoomMenuOpen(false); }} > 缩放至{Math.round(scale * 100)}% ))} ) : null}
setIsBackgroundMenuOpen((open) => !open)} icon={ } /> {isBackgroundMenuOpen ? ( {CANVAS_BACKGROUND_OPTIONS.map((option) => ( { setCanvasBackgroundColor(option.value); setIsBackgroundMenuOpen(false); }} > ))} ) : null}
toggleSidebarPanel('assets')} /> toggleSidebarPanel('layers')} /> setIsMinimapOpen((open) => !open)} />
{isMinimapOpen && minimapModel ? ( ) : null}
event.stopPropagation()} > {canvasTools.map(({ id, label, icon: Icon }) => id === 'spec' ? ( switchTool(id)} /> ) : ( switchTool(id)} /> ), )}
{isSpecMenuOpen ? renderEditorPortal( {(['character', 'ui', 'custom'] as const).map((specType) => ( openSpecDialog(specType)} > {SPEC_TYPE_LABEL[specType]} ))} , ) : null} {generateDialog?.mode === 'generate' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} > { setUploadTarget('asset'); uploadInputRef.current?.click(); }} icon={} > 参考图 setGenerateDialog((currentDialog) => currentDialog ? { ...currentDialog, prompt: event.target.value, status: currentDialog.status === 'failed' ? 'idle' : currentDialog.status, errorMessage: currentDialog.status === 'failed' ? undefined : currentDialog.errorMessage, } : currentDialog, ) } />
triggerPlaceholderAction('生成参数')} trailingIcon={} > 中 · 1:1(2k) · 1张 triggerPlaceholderAction('模型选择')} trailingIcon={} > GPT Im... {generateDialog.status === 'generating' ? '生成中' : '12'}
{generateDialog.status === 'generating' ? ( 生成中 ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} { setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' ? { ...currentDialog, composerOpen: false, } : currentDialog, ); setActiveTool('select'); }} /> ) : null} {generateDialog?.mode === 'spec' && generateDialog.composerOpen !== false && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
{generateDialog.specType === 'custom' ? ( ) : ( <> {generateDialog.specType === 'character' ? ( <> ) : null} )}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
{generateDialog.status === 'generating' ? '生成中' : `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
) : null} {generateDialog?.mode === 'character' && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
角色形象规范
{isCharacterSpecMenuOpen ? renderEditorPortal( { setIsPickingCharacterSpecFromCanvas(true); setIsCharacterSpecMenuOpen(false); }} > 从画布中选择 { setIsCharacterSpecMenuOpen(false); openSpecDialog('character'); }} > 新建角色形象规范 { setUploadTarget('character-spec'); setIsCharacterSpecMenuOpen(false); uploadInputRef.current?.click(); }} > 上传图片 , ) : null}
常规参考图
{(generateDialog.characterReferences ?? []).map( (reference, index) => ( {reference.label} {index + 1} ), )}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
画面比例 triggerPlaceholderAction('角色比例')} > 1:1
模型 triggerPlaceholderAction('角色模型')} > GPT Image
{generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {generateDialog?.mode === 'icon' && generateDialog.composerOpen !== false && iconComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitIconSpritesheetGeneration(generateDialog); } }} >
图标素材规范
{isIconSpecMenuOpen ? renderEditorPortal( { setIsPickingIconSpecFromCanvas(true); setIsIconSpecMenuOpen(false); }} > 从画布中选择 { setIsIconSpecMenuOpen(false); openSpecDialog('icon'); }} > 新建图标素材规范 { setUploadTarget('icon-spec'); setIsIconSpecMenuOpen(false); uploadInputRef.current?.click(); }} > 上传图片 , ) : null}
素材描述
{iconDescriptionValues.map((description, index) => ( ))}
{generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null}
模型 triggerPlaceholderAction('图标模型')} > nanobanana2
{generateDialog.status === 'generating' ? '生成中' : '生成'}
) : null} {isPickingCharacterSpecFromCanvas ? (
请选择画布中的图片作为角色形象规范,按 Esc 退出
) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标素材规范,按 Esc 退出
) : null} {quickEditPanel && quickEditPanel.status !== 'generating' && quickEditSourceLayer && quickEditPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); void submitQuickEdit(); }} >
{`${quickEditSourceLayer.title}参考图`} {quickEditSourceLayer.title}
setQuickEditPanel(null)} />
setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, prompt: event.target.value, status: currentPanel.status === 'failed' ? 'idle' : currentPanel.status, errorMessage: currentPanel.status === 'failed' ? undefined : currentPanel.errorMessage, } : currentPanel, ) } />
setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, size: event.target.value } : currentPanel, ) } > {quickEditSizeOptions.map((size) => ( ))} setQuickEditPanel((currentPanel) => currentPanel ? { ...currentPanel, model: event.target.value } : currentPanel, ) } > {quickEditModelOptions.map((option) => ( ))}
{quickEditPanel.status === 'failed' ? ( {quickEditPanel.errorMessage} ) : null} 生成 ) : null} {imageContextMenu && imageContextMenuLayer ? (
event.stopPropagation()} > openQuickEditPanel(imageContextMenuLayer)} > 快速编辑 { setMetadataLayer(imageContextMenuLayer); setImageContextMenu(null); }} > 查看图片信息 {imageContextMenuLayer.assetKind === 'character' ? ( openCharacterAnimationPanel(imageContextMenuLayer) } > 生成动画 ) : null} deleteLayerById(imageContextMenuLayer.id)} > 删除图片
) : null} {characterAnimationPanel && characterAnimationSourceLayer && characterAnimationPanelStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (characterAnimationPanel.status !== 'generating') { void submitCharacterAnimation(); } }} >
角色动画 setCharacterAnimationPanel(null)} />
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, ) } />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( ))}
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, ) } > 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) => ( ))} updateCharacterAnimationDuration(event.target.value) } > {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( ))}
{characterAnimationPanel.promptText.trim() ? characterAnimationPanel.promptText.trim() : '动画描述'} {characterAnimationPrice}泥点
{characterAnimationPanel.status === 'completed' && characterAnimationPanel.result ? ( 已生成 {characterAnimationPanel.result.frameCount} 帧 ) : null} {characterAnimationPanel.status === 'failed' ? ( {characterAnimationPanel.errorMessage} ) : null} {characterAnimationPanel.status === 'generating' ? '生成中' : '生成'} ) : null}
setMetadataLayer(null)} panelClassName="image-canvas-editor__metadata-dialog" bodyClassName="image-canvas-editor__metadata-body" > {metadataLayer ? (
图片类型
{formatLayerImageType(metadataLayer)}
Prompt
{metadataLayer.prompt ? ( <> {metadataLayer.prompt} { void navigator.clipboard?.writeText( metadataLayer.prompt ?? '', ); }} > 复制Prompt ) : ( '-' )}
Model
{metadataLayer.model ?? '-'}
Size
{Math.round(metadataLayer.width)} x{' '} {Math.round(metadataLayer.height)} px
Resolution
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
Provider
{metadataLayer.provider ?? '-'}
Task
{metadataLayer.taskId ?? '-'}
Object
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
) : null}
setGenerateDialog(null)} panelClassName="image-canvas-editor__generate-dialog" bodyClassName="image-canvas-editor__generate-dialog-body" > {generateDialog?.mode === 'edit' ? (
{ event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} >
setGenerateDialog((currentDialog) => currentDialog ? { ...currentDialog, prompt: event.target.value, } : currentDialog, ) } /> {generateDialog.status === 'generating' ? ( {generateDialog.mode === 'edit' ? '修改中' : '生成中'} ) : null} {generateDialog.status === 'failed' ? ( {generateDialog.errorMessage} ) : null} {generateDialog.status === 'generating' ? generateDialog.mode === 'edit' ? '修改中' : '生成中' : generateDialog.mode === 'edit' ? '修改' : '生成'}
) : null}
); } function resolveSnappedLayerPosition( movingLayer: CanvasLayer, proposedX: number, proposedY: number, layers: CanvasLayer[], scale: number, ) { const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE); const verticalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...layers .filter((layer) => layer.id !== movingLayer.id) .flatMap((layer) => [ layer.x, layer.x + layer.width / 2, layer.x + layer.width, ]), ]; const horizontalTargets = [ 0, CANVAS_WORLD_ORIGIN, ...layers .filter((layer) => layer.id !== movingLayer.id) .flatMap((layer) => [ layer.y, layer.y + layer.height / 2, layer.y + layer.height, ]), ]; const xSnap = findNearestSnap( proposedX, [0, movingLayer.width / 2, movingLayer.width], verticalTargets, threshold, ); const ySnap = findNearestSnap( proposedY, [0, movingLayer.height / 2, movingLayer.height], horizontalTargets, threshold, ); return { x: xSnap ? xSnap.position : proposedX, y: ySnap ? ySnap.position : proposedY, guide: xSnap || ySnap ? { vertical: xSnap?.guide, horizontal: ySnap?.guide, } : null, }; } function findNearestSnap( origin: number, offsets: number[], targets: number[], threshold: number, ): SnapCandidate | null { let nearest: SnapCandidate | null = null; for (const offset of offsets) { for (const target of targets) { const distance = Math.abs(target - (origin + offset)); if (distance > threshold) { continue; } if (!nearest || distance < nearest.distance) { nearest = { position: target - offset, guide: target, distance, }; } } } return nearest; } export default ImageCanvasEditorView;