Files
Genarrative/src/components/image-editor/ImageCanvasLayerCommandModel.ts
T
lhk229 cd80c085ff 实现 SFX V2 T1 契约与提示词基础
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界
固化 52 个音效预设、追加规则及跨语言测试向量
演进共享音频请求响应、duration、Loop 和 V2 metadata 契约
增加 External model 输入矩阵纯函数并保持正式接线归属 T5
补齐前端提交、读取、深拷贝与请求边界回归测试
更新共享计划并标记 T1 完成且当前不可发布
2026-08-06 13:06:10 +00:00

260 lines
6.3 KiB
TypeScript

import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom';
export type CanvasLayerFlipAxis = 'x' | 'y';
export type CanvasLayerCopyBlockReason =
| 'pending-resource'
| 'self-contained-local-unsupported'
| 'unresolved-resource';
type CanvasPoint = {
x: number;
y: number;
};
function cloneLayer(layer: CanvasLayer): CanvasLayer {
return {
...layer,
generationInputs: layer.generationInputs
? {
...layer.generationInputs,
fields: layer.generationInputs.fields.map((field) => ({ ...field })),
references: layer.generationInputs.references.map((reference) => ({
...reference,
})),
...(layer.generationInputs.soundEffect
? { soundEffect: { ...layer.generationInputs.soundEffect } }
: {}),
}
: layer.generationInputs,
};
}
export function resolveContextTargetLayerIds(
menu: CanvasContextMenuState | null,
selectedLayerIds: string[],
) {
if (!menu || menu.kind === 'blank') {
return [];
}
const targetId =
menu.kind === 'generation-dialog'
? getCanvasGenerationSelectionId(menu.dialogId)
: menu.layerId;
return selectedLayerIds.includes(targetId)
? [...selectedLayerIds]
: [targetId];
}
export function getCanvasLayersByIds(
layers: CanvasLayer[],
targetIds: string[],
) {
return layers.filter((layer) => targetIds.includes(layer.id));
}
export function getCanvasLayerCopyBlockReason(
layers: CanvasLayer[],
): CanvasLayerCopyBlockReason | null {
for (const layer of layers) {
if (layer.resourcePersistenceState === 'registered') {
continue;
}
if (layer.resourcePersistenceState === 'pending') {
return 'pending-resource';
}
if (layer.resourcePersistenceState === 'self-contained-local') {
return 'self-contained-local-unsupported';
}
if (
layer.resourcePersistenceState === 'unresolved-local' ||
layer.resourceId.startsWith('local-')
) {
return 'unresolved-resource';
}
}
return null;
}
export function canCopyCanvasLayers(layers: CanvasLayer[]) {
return layers.length > 0 && getCanvasLayerCopyBlockReason(layers) === null;
}
export function duplicateCanvasLayers({
sourceLayers,
allLayers,
canvasPoint,
renameCopies = true,
stamp = Date.now(),
}: {
sourceLayers: CanvasLayer[];
allLayers: CanvasLayer[];
canvasPoint?: CanvasPoint;
renameCopies?: boolean;
stamp?: number | string;
}) {
if (!canCopyCanvasLayers(sourceLayers)) {
return [];
}
const minX = Math.min(...sourceLayers.map((layer) => layer.x));
const minY = Math.min(...sourceLayers.map((layer) => layer.y));
const maxZIndex = allLayers.reduce(
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
0,
);
return sourceLayers.map((layer, index) => ({
...cloneLayer(layer),
id: `layer-copy-${stamp}-${index}`,
title: renameCopies ? `${layer.title} 副本` : layer.title,
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
zIndex: maxZIndex + index + 1,
groupId: null,
}));
}
export function createCanvasLayerClipboard(
layers: CanvasLayer[],
targetIds: string[],
mode: CanvasClipboard['mode'],
): CanvasClipboard | null {
const targetLayers = getCanvasLayersByIds(layers, targetIds);
if (!canCopyCanvasLayers(targetLayers)) {
return null;
}
return {
layers: targetLayers.map(cloneLayer),
mode,
};
}
export function removeCanvasLayers(layers: CanvasLayer[], targetIds: string[]) {
if (!targetIds.length) {
return layers;
}
return layers.filter((layer) => !targetIds.includes(layer.id));
}
export function updateCanvasLayersByIds(
layers: CanvasLayer[],
targetIds: string[],
updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer,
) {
if (!targetIds.length) {
return layers;
}
return layers.map((layer) =>
targetIds.includes(layer.id) ? updater(layer, targetIds) : layer,
);
}
export function moveCanvasLayers(
layers: CanvasLayer[],
targetIds: string[],
mode: CanvasLayerMoveMode,
) {
if (!targetIds.length) {
return layers;
}
const maxZIndex = layers.reduce(
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
0,
);
const minZIndex = layers.reduce(
(minZ, layer) => Math.min(minZ, layer.zIndex),
0,
);
let offsetIndex = 0;
return updateCanvasLayersByIds(layers, targetIds, (layer) => {
if (mode === 'up') {
return { ...layer, zIndex: layer.zIndex + 1 };
}
if (mode === 'down') {
return { ...layer, zIndex: layer.zIndex - 1 };
}
offsetIndex += 1;
if (mode === 'top') {
return { ...layer, zIndex: maxZIndex + offsetIndex };
}
return {
...layer,
zIndex: minZIndex - (targetIds.length - offsetIndex + 1),
};
});
}
export function groupCanvasLayers(
layers: CanvasLayer[],
targetIds: string[],
groupId: string,
) {
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
...layer,
groupId,
}));
}
export function ungroupCanvasLayers(
layers: CanvasLayer[],
targetIds: string[],
) {
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
...layer,
groupId: null,
}));
}
export function toggleCanvasLayersVisibility(
layers: CanvasLayer[],
targetIds: string[],
) {
const shouldHide = getCanvasLayersByIds(layers, targetIds).some(
(layer) => !layer.hidden,
);
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
...layer,
hidden: shouldHide,
}));
}
export function toggleCanvasLayersLock(
layers: CanvasLayer[],
targetIds: string[],
) {
const shouldLock = getCanvasLayersByIds(layers, targetIds).some(
(layer) => !layer.locked,
);
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
...layer,
locked: shouldLock,
}));
}
export function flipCanvasLayers(
layers: CanvasLayer[],
targetIds: string[],
axis: CanvasLayerFlipAxis,
) {
return updateCanvasLayersByIds(layers, targetIds, (layer) =>
axis === 'x'
? {
...layer,
flipX: !layer.flipX,
}
: {
...layer,
flipY: !layer.flipY,
},
);
}