import { Braces, Check, CheckSquare, ChevronDown, ChevronRight, 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 DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent as ReactWheelEvent, } from 'react'; import { createEditorProjectResource, createEditorAsset, createEditorAssetFolder, deleteEditorAsset, deleteEditorAssetFolder, editEditorImage, type EditorAssetLibrarySnapshot, type EditorImageGenerationResult, type EditorProjectLayerSnapshot, generateEditorImage, loadEditorAssetLibrary, loadEditorProject, loadOrCreateRecentEditorProject, saveEditorProjectLayout, updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; import { ApiClientError } from '../../services/apiClient'; import { EditorIconButton, SidebarMediaItem, } from './ImageCanvasEditorPrimitives'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; 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; }; type CanvasViewport = { x: number; y: number; scale: number; }; type CanvasTool = | 'select' | 'hand' | 'upload' | 'generate' | 'text' | 'shape' | 'export'; type SidebarPanel = 'assets' | 'layers'; type EditorAssetFolder = { id: string; label: string; collapsed: boolean; systemDefault: boolean; persisted: boolean; }; type GenerateDialogState = { mode: 'generate' | 'edit'; prompt: string; status: 'idle' | 'generating' | 'failed'; sourceLayerId?: string; generatedLayerId?: string; errorMessage?: string; placeholder?: { x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; }; }; 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 DragState = | { kind: 'pan'; pointerId: number; startClientX: number; startClientY: number; startViewport: CanvasViewport; } | { kind: 'layer'; pointerId: number; layerId: string; startClientX: number; startClientY: number; startLayerX: number; startLayerY: number; startScale: number; } | { kind: 'generation-frame'; pointerId: number; startClientX: number; startClientY: number; startFrameX: number; startFrameY: number; startScale: number; } | { kind: 'minimap'; pointerId: number; }; 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: 'references', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'bark-battle', label: '声浪素材', src: '/creation-type-references/bark-battle.webp', width: 640, height: 900, folderId: 'references', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, { id: 'visual-novel', label: '视觉小说素材', src: '/creation-type-references/visual-novel.webp', width: 720, height: 405, folderId: 'references', sourceKind: 'built-in', sourceType: 'uploaded', persisted: false, }, ]; const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: false, }, { id: 'references', label: '参考素材', collapsed: false, systemDefault: false, persisted: false, }, { id: 'uploads', label: '上传素材', collapsed: false, systemDefault: false, 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 CANVAS_BACKGROUND_OPTIONS = [ { label: '白色', value: '#ffffff' }, { label: '浅灰', value: '#f8fafc' }, { label: '暖灰', value: '#f3f0ea' }, { label: '冷蓝', value: '#eef6ff' }, ]; 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 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, src: layer.src, 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, }; } function hydrateLayer(snapshot: EditorProjectLayerSnapshot): CanvasLayer | null { const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const src = typeof snapshot.src === 'string' ? snapshot.src : ''; 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), }; } 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 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 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 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 canvasViewportRef = useRef(null); const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); const layerCounterRef = useRef(INITIAL_LAYERS.length); const saveTimerRef = useRef(null); 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('uploads'); const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); const [selectedAssetIds, setSelectedAssetIds] = useState>( () => new Set(), ); const [assetMarquee, setAssetMarquee] = 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 [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 effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; const selectedLayer = useMemo( () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], ); const activeGenerationLayer = useMemo( () => generateDialog?.mode === 'generate' && generateDialog.generatedLayerId ? layers.find((layer) => layer.id === generateDialog.generatedLayerId) ?? null : null, [generateDialog, layers], ); const generationAnchor = generateDialog?.mode === 'generate' ? (activeGenerationLayer ?? generateDialog.placeholder ?? null) : null; const generationComposerStyle = generationAnchor ? { left: viewport.x + (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, top: viewport.y + (generationAnchor.y + generationAnchor.height) * viewport.scale + 10, } : null; const selectedToolbarStyle = selectedLayer ? { left: clamp( viewport.x + selectedLayer.x * viewport.scale + (selectedLayer.width * viewport.scale) / 2, TOOLBAR_HALF_WIDTH, Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH), ), top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), } : null; const groupedAssets = useMemo( () => assetFolders.map((folder) => ({ ...folder, assets: assets.filter((asset) => asset.folderId === folder.id), })), [assetFolders, assets], ); const selectableAssets = useMemo( () => assets.filter((asset) => asset.sourceKind === 'uploaded'), [assets], ); const allSelectableAssetsSelected = selectableAssets.length > 0 && selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); const selectSingleLayer = (layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); }; 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; } setProjectId(project.projectId); setViewport(project.viewport); const hydratedLayers = project.layers .map(hydrateLayer) .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; }; }, []); 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 === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); setIsBackgroundMenuOpen(false); setGenerateDialog((currentDialog) => currentDialog?.status === 'generating' ? currentDialog : null, ); return; } if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { return; } event.preventDefault(); setIsSpacePanning(true); }; const handleKeyUp = (event: KeyboardEvent) => { 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(() => { 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 createProjectResourceForLayer = ( layer: CanvasLayer, options: { onCreated?: (resourceId: string) => void } = {}, ) => { if (!projectId) { return; } createEditorProjectResource(projectId, { 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 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 addUploadedLayer = async ( file: File, options: { folderId?: string; canvasPoint?: { x: number; y: number }; uploadIndex?: number; } = {}, ) => { 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) : 'uploads'; const screenPoint = options.canvasPoint ?? { x: canvasSize.width / 2, y: canvasSize.height / 2, }; const worldCenterX = (screenPoint.x - viewport.x) / viewport.scale; const worldCenterY = (screenPoint.y - viewport.y) / viewport.scale; 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, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => currentFolders.map((folder) => folder.id === uploadFolderId ? { ...folder, collapsed: false, } : folder, ), ); 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(() => {}); 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); 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 } } = {}, ) => { Array.from(files).forEach((file, index) => { layerCounterRef.current += 1; const uploadIndex = layerCounterRef.current; void addUploadedLayer(file, { ...options, uploadIndex, canvasPoint: options.canvasPoint ? { x: options.canvasPoint.x + index * 28, y: options.canvasPoint.y + index * 28, } : undefined, }); }); }; const deleteSelectedLayer = () => { if (!selectedLayerId) { return; } setLayers((currentLayers) => { const nextLayers = currentLayers.filter((layer) => layer.id !== selectedLayerId); const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; selectSingleLayer(nextSelectedLayer?.id ?? null); return nextLayers; }); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer?.id === selectedLayerId ? null : currentLayer, ); }; 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; setGenerateDialog({ mode: 'generate', prompt: '', status: 'idle', placeholder: { x: worldCenterX - placeholderWidth / 2, y: worldCenterY - placeholderHeight / 2, width: placeholderWidth, height: placeholderHeight, originalWidth: 2048, originalHeight: 2048, }, }); setActiveTool('generate'); selectSingleLayer(null); }; const openEditDialog = (sourceLayer: CanvasLayer) => { setMetadataLayer(null); setGenerateDialog({ mode: 'edit', prompt: sourceLayer.prompt ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` : '', status: 'idle', sourceLayerId: sourceLayer.id, }); setActiveTool('generate'); }; const addGeneratedResultLayer = ( generated: EditorImageGenerationResult, options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder'] } = {}, ) => { 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} 修改结果` : `生成图片 ${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, prompt: generated.prompt, actualPrompt: generated.actualPrompt ?? generated.prompt, model: generated.model, provider: generated.provider, taskId: generated.taskId, sourceResourceId: options.sourceLayer?.resourceId, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (options.sourceLayer) { setGenerateDialog(null); setActiveTool('select'); } else { setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' ? { ...currentDialog, status: 'idle', generatedLayerId: nextLayer.id, placeholder: undefined, errorMessage: undefined, } : currentDialog, ); } if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } createProjectResourceForLayer(nextLayer); }; const submitImageGeneration = async (dialog: GenerateDialogState) => { const normalizedPrompt = dialog.prompt.trim() || (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'generating', }); try { if (dialog.mode === 'edit') { const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId); if (!sourceLayer) { throw new Error('未找到要修改的图片'); } if (!sourceLayer.src.startsWith('data:image/')) { throw new Error('当前图片缺少可提交的原图数据,请先使用生成图片结果进行修改'); } const generated = await editEditorImage({ prompt: normalizedPrompt, sourceImageSrc: sourceLayer.src, }); addGeneratedResultLayer(generated, { sourceLayer }); } else { const generated = await generateEditorImage({ prompt: normalizedPrompt }); addGeneratedResultLayer(generated, { frame: dialog.placeholder }); } } catch (error) { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, status: 'failed', 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; } selectSingleLayer(null); }; 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, }); }; 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; } event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); selectSingleLayer(layer.id); dragStateRef.current = { kind: 'layer', pointerId: getPointerId(event), layerId: layer.id, startClientX: pointer.x, startClientY: pointer.y, startLayerX: layer.x, startLayerY: layer.y, startScale: viewport.scale, }; }; const handleGenerationFramePointerDown = ( event: ReactPointerEvent, ) => { if (!generateDialog?.placeholder) { return; } const button = getPointerButton(event); if (button === 1 || effectiveTool === 'hand') { event.stopPropagation(); startPan(event as unknown as ReactPointerEvent); return; } if (button !== 0 || generateDialog.status === 'generating') { return; } event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); selectSingleLayer(null); dragStateRef.current = { kind: 'generation-frame', pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, startFrameX: generateDialog.placeholder.x, startFrameY: generateDialog.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 handleMinimapPointerDown = ( event: ReactPointerEvent, ) => { event.preventDefault(); event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); dragStateRef.current = { kind: 'minimap', pointerId: getPointerId(event), }; moveViewportFromMinimapPointer(pointer.x, pointer.y); }; const handlePointerMove = (event: ReactPointerEvent) => { 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; setGenerateDialog((currentDialog) => currentDialog?.mode === 'generate' && currentDialog.placeholder ? { ...currentDialog, placeholder: { ...currentDialog.placeholder, x: dragState.startFrameX + deltaX, y: dragState.startFrameY + deltaY, }, } : currentDialog, ); return; } if (dragState.kind === 'minimap') { const pointer = getPointerClient(event); moveViewportFromMinimapPointer(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) => layer.id === dragState.layerId ? { ...layer, x: snapped.x, y: snapped.y, } : layer, ), ); }; const finishDrag = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; const pointerId = getPointerId(event); if ( dragState && (dragState.pointerId < 0 || pointerId < 0 || dragState.pointerId === pointerId) ) { 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') { uploadInputRef.current?.click(); return; } if (tool === 'generate') { openGenerateDialog(); 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: 'text', label: '文字工具', icon: Type }, { id: 'shape', label: '形状标注工具', icon: Shapes }, { id: 'export', label: '导出工具', icon: Download }, ]; return (
event.preventDefault()} > { const files = event.currentTarget.files; if (files?.length) { addUploadedFiles(files); } event.currentTarget.value = ''; }} /> {activeSidebarPanel ? ( ) : null}

图片编辑器

画布
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}
{snapGuide?.vertical !== undefined ? (
) : null} {snapGuide?.horizontal !== undefined ? (
) : null} {layers .slice() .sort((left, right) => left.zIndex - right.zIndex) .map((layer) => { const isSelected = selectedLayerId === layer.id; const isHovered = hoveredLayerId === layer.id; return ( ); })} {generateDialog?.mode === 'generate' && generateDialog.placeholder ? (
Image Generator {generateDialog.placeholder.originalWidth} x{' '} {generateDialog.placeholder.originalHeight}
) : null}
{selectedLayer && selectedToolbarStyle ? (
event.stopPropagation()} > {toolButtons.map(({ label, icon: Icon }) => ( triggerPlaceholderAction(label)} /> ))} {isGeneratedLayer(selectedLayer) ? ( <> setMetadataLayer(selectedLayer)} /> openEditDialog(selectedLayer)} /> ) : null}
) : null} fitLayers()} />
event.stopPropagation()} >
{isBackgroundMenuOpen ? ( {CANVAS_BACKGROUND_OPTIONS.map((option) => ( { setCanvasBackgroundColor(option.value); setIsBackgroundMenuOpen(false); }} > ))} ) : null}
toggleSidebarPanel('assets')} /> toggleSidebarPanel('layers')} /> setIsMinimapOpen((open) => !open)} />
{isMinimapOpen && minimapModel ? ( ) : null} {generateDialog?.mode === 'generate' ? null : (
event.stopPropagation()} > {canvasTools.map(({ id, label, icon: Icon }) => ( switchTool(id)} /> ))}
)} {generateDialog?.mode === 'generate' && generationComposerStyle ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (generateDialog.status !== 'generating') { void submitImageGeneration(generateDialog); } }} > 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(null); setActiveTool('select'); }} /> ) : null}
setMetadataLayer(null)} panelClassName="image-canvas-editor__metadata-dialog" bodyClassName="image-canvas-editor__metadata-body" > {metadataLayer ? (
来源
{metadataLayer.sourceType}
尺寸
{metadataLayer.originalWidth} x {metadataLayer.originalHeight}
模型
{metadataLayer.model ?? '-'}
服务
{metadataLayer.provider ?? '-'}
任务
{metadataLayer.taskId ?? '-'}
对象
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
Prompt
{metadataLayer.prompt ?? '-'}
) : 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;