import type { EditorAssetLibrarySnapshot, EditorProjectLayerSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, CanvasGenerationInputs, CanvasLayer, CanvasContextMenuState, CanvasViewport, EditorAsset, EditorAssetFolder, SnapCandidate, } from './ImageCanvasEditorTypes'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: false, }, ]; export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.24; export const MAX_SCALE = 3.2; export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; export const MINIMAP_DRAG_SENSITIVITY = 0.3; export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; export const MAX_HISTORY_STEPS = 60; export const CONTEXT_MENU_VIEWPORT_MARGIN = 8; export const CONTEXT_MENU_SIZE = { blank: { width: 188, height: 176 }, layer: { width: 188, height: 492 }, } as const; export const CANVAS_BACKGROUND_OPTIONS = [ { label: '白色', value: '#ffffff' }, { label: '浅灰', value: '#f8fafc' }, { label: '暖灰', value: '#f3f0ea' }, { label: '冷蓝', value: '#eef6ff' }, ]; export const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; export function normalizeCanvasBackgroundHex(value: string) { const trimmedValue = value.trim().toLowerCase(); const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue); if (!match) { return null; } const hexValue = match[1] ?? ''; if (hexValue.length === 3) { return `#${hexValue .split('') .map((part) => `${part}${part}`) .join('')}`; } return `#${hexValue}`; } export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } export 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}`; } export function resolveLayerResolutionSize( originalWidth: number, originalHeight: number, fallback: { width: number; height: number }, ) { // 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。 return { width: Math.max(1, Math.round(originalWidth || fallback.width || 1)), height: Math.max(1, Math.round(originalHeight || fallback.height || 1)), }; } export function createLayerFromAsset( asset: EditorAsset, index: number, viewport: CanvasViewport, screenCenter: { x: number; y: number }, ): CanvasLayer { const { width, height } = resolveLayerResolutionSize( asset.width, asset.height, { width: 360, height: 360 }, ); const safeScale = viewport.scale > 0 ? viewport.scale : 1; const safeScreenCenter = { x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0, y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0, }; const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; 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, sourceAssetId: asset.id, } satisfies CanvasLayer; } export 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, sourceAssetId: layer.sourceAssetId, groupId: layer.groupId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, hidden: layer.hidden, locked: layer.locked, flipX: layer.flipX, flipY: layer.flipY, }; } export 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), ...(() => { const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320); const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320); return { ...resolveLayerResolutionSize(originalWidth, originalHeight, { width: numberFromSnapshot(snapshot.width, 320), height: numberFromSnapshot(snapshot.height, 320), }), originalWidth, originalHeight, }; })(), 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), sourceAssetId: stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(snapshot.assetKind), generationInputs: generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), flipY: booleanFromSnapshot(snapshot.flipY), }; } export 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, })), }; } export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); let hasDefaultFolder = false; const normalizedFolders = mapped.folders.filter((folder) => { if (!folder.systemDefault) { return true; } if (hasDefaultFolder) { return false; } hasDefaultFolder = true; return true; }); const persistedFolderIds = new Set( normalizedFolders.map((folder) => folder.id), ); const fallbackFolders = hasDefaultFolder ? [] : EDITOR_ASSET_FOLDERS.filter( (folder) => !persistedFolderIds.has(folder.id), ); return { folders: [...normalizedFolders, ...fallbackFolders], assets: mapped.assets, }; } export function numberFromSnapshot(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } export function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } export function booleanFromSnapshot(value: unknown) { return value === true; } export function resolveContextMenuPosition( clientX: number, clientY: number, kind: CanvasContextMenuState['kind'], ) { if (typeof window === 'undefined') { return { x: clientX, y: clientY }; } const menuSize = CONTEXT_MENU_SIZE[kind]; return { x: clamp( clientX, CONTEXT_MENU_VIEWPORT_MARGIN, Math.max( CONTEXT_MENU_VIEWPORT_MARGIN, window.innerWidth - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN, ), ), y: clamp( clientY, CONTEXT_MENU_VIEWPORT_MARGIN, Math.max( CONTEXT_MENU_VIEWPORT_MARGIN, window.innerHeight - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN, ), ), }; } export function hasDataTransferType( dataTransfer: DataTransfer, type: string, ) { return Array.from(dataTransfer.types).includes(type); } export function getDraggedAssetId(dataTransfer: DataTransfer) { if (typeof dataTransfer.getData !== 'function') { return ''; } if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) { return ''; } return dataTransfer.getData(ASSET_DRAG_MIME_TYPE); } export function escapeCssIdentifier(value: string) { return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/gu, '\\$&'); } export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { return ( layer.sourceAssetId === asset.id || Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) || Boolean(asset.objectKey && layer.objectKey === asset.objectKey) || layer.src === asset.src ); } export function generationInputsOrNull( value: unknown, ): CanvasGenerationInputs | null { if (!value || typeof value !== 'object') { return null; } const snapshot = value as { fields?: unknown; references?: unknown; }; const fields = Array.isArray(snapshot.fields) ? snapshot.fields.flatMap((field) => { if (!field || typeof field !== 'object') { return []; } const item = field as { title?: unknown; value?: unknown }; const title = stringOrNull(item.title); const fieldValue = stringOrNull(item.value); return title && fieldValue ? [{ title, value: fieldValue }] : []; }) : []; const references = Array.isArray(snapshot.references) ? snapshot.references.flatMap((reference) => { if (!reference || typeof reference !== 'object') { return []; } const item = reference as { title?: unknown; label?: unknown; src?: unknown; }; const title = stringOrNull(item.title); const label = stringOrNull(item.label); const src = stringOrNull(item.src); return title && label && src ? [{ title, label, src }] : []; }) : []; return fields.length || references.length ? { fields, references } : null; } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { return value === 'spec' || value === 'character' || value === 'icon' || value === 'icon-spec' ? value : null; } export function isCanvasSourceType( value: unknown, ): value is CanvasLayer['sourceType'] { return ( value === 'uploaded' || value === 'generated' || value === 'mock_generated' ); } export function isGeneratedLayer(layer: CanvasLayer) { return ( layer.sourceType === 'generated' || layer.sourceType === 'mock_generated' ); } export 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, }, ); } export 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, }; } export 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; }