拆分图片画布编辑器前端模型
抽出编辑器共享类型、画布模型、生成模型和导出模型 补充模型层单测覆盖素材、吸附、生成快照和导出规则 新增前端拆分计划并更新 TRACKING 浏览器回归记录
This commit is contained in:
529
src/components/image-editor/ImageCanvasEditorModel.ts
Normal file
529
src/components/image-editor/ImageCanvasEditorModel.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user