Files
Genarrative/src/components/image-editor/ImageCanvasEditorModel.ts
kdletters 1f5605331f 拆分图片画布编辑器前端模型
抽出编辑器共享类型、画布模型、生成模型和导出模型

补充模型层单测覆盖素材、吸附、生成快照和导出规则

新增前端拆分计划并更新 TRACKING 浏览器回归记录
2026-06-17 01:53:59 +08:00

530 lines
15 KiB
TypeScript
Raw Blame History

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