合并master并保留Spine序列帧修复

合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
2026-08-14 21:33:26 +08:00
338 changed files with 69729 additions and 8828 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@genarrative/image-canvas-core",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
@@ -0,0 +1,59 @@
import type { CanvasLayer } from './types';
export type CanvasExportLayer = {
id: string;
title: string;
mediaType: 'image';
mediaRef: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
flipX: boolean;
flipY: boolean;
};
export type CanvasExportModel = {
bounds: { x: number; y: number; width: number; height: number };
backgroundColor: string;
layers: CanvasExportLayer[];
};
export function createCanvasExportModel({
layers,
backgroundColor,
}: {
layers: CanvasLayer[];
backgroundColor: string;
}): CanvasExportModel | null {
const visibleImages = layers
.filter(
(layer) => !layer.hidden && (layer.mediaType ?? 'image') === 'image',
)
.sort((first, second) => first.zIndex - second.zIndex);
if (visibleImages.length === 0) return null;
const minX = Math.min(...visibleImages.map((layer) => layer.x));
const minY = Math.min(...visibleImages.map((layer) => layer.y));
const maxX = Math.max(...visibleImages.map((layer) => layer.x + layer.width));
const maxY = Math.max(
...visibleImages.map((layer) => layer.y + layer.height),
);
return {
bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
backgroundColor,
layers: visibleImages.map((layer) => ({
id: layer.id,
title: layer.title,
mediaType: 'image',
mediaRef: layer.objectKey ?? layer.assetObjectId ?? layer.src,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
flipX: Boolean(layer.flipX),
flipY: Boolean(layer.flipY),
})),
};
}
+290
View File
@@ -0,0 +1,290 @@
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistorySnapshot,
CanvasLayer,
GenerateDialogState,
} from './types';
const CANVAS_HISTORY_ACTION_LABELS: Record<
CanvasHistoryAction['type'],
string
> = {
'move-image': '移动图片',
'resize-image': '缩放图片',
'move-generation-result': '移动生成结果',
'delete-image': '删除图片',
'delete-generation-result': '删除生成结果',
'cut-image': '剪切图片',
'paste-image': '粘贴图片',
'duplicate-image': '复制图片',
'add-image': '添加图片',
'upload-image': '上传图片',
'generate-image': '生成图片',
'expand-image': '扩展图片',
'remove-background': '移除背景',
'perfect-pixel': '完美像素',
'split-atlas': '拆分图集',
'replace-image': '替换图片',
'show-image': '显示图片',
'hide-image': '隐藏图片',
'change-layer-order': '调整图片层级',
'group-images': '组合图片',
'ungroup-images': '取消组合',
'lock-image': '锁定图片',
'unlock-image': '解锁图片',
'flip-image': '翻转图片',
'change-asset-kind': '修改素材类型',
'change-viewport': '调整画布视图',
};
const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
CanvasHistoryAction['type']
>([
'paste-image',
'duplicate-image',
'add-image',
'upload-image',
'generate-image',
'expand-image',
'remove-background',
'perfect-pixel',
'split-atlas',
'replace-image',
]);
export function formatCanvasHistoryAction(action: CanvasHistoryAction): string {
return CANVAS_HISTORY_ACTION_LABELS[action.type];
}
export function isProtectedCanvasHistoryAction(
action: CanvasHistoryAction,
): boolean {
return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type);
}
function hasSameStableMediaReference(
current: {
src: string;
objectKey?: string | null;
assetObjectId?: string | null;
},
target: {
src: string;
objectKey?: string | null;
assetObjectId?: string | null;
},
): boolean {
if (current.objectKey && target.objectKey) {
return current.objectKey === target.objectKey;
}
if (current.assetObjectId && target.assetObjectId) {
return current.assetObjectId === target.assetObjectId;
}
return current.src === target.src;
}
function hasSameImageSequenceFrames(
current: CanvasLayer,
target: CanvasLayer,
): boolean {
const currentFrames = current.imageSequenceFrames ?? [];
const targetFrames = target.imageSequenceFrames ?? [];
if (currentFrames.length !== targetFrames.length) {
return false;
}
return currentFrames.every((currentFrame, index) => {
const targetFrame = targetFrames[index];
return (
targetFrame !== undefined &&
hasSameStableMediaReference(
{
src: currentFrame.imageSrc,
objectKey: currentFrame.objectKey,
assetObjectId: currentFrame.assetObjectId,
},
{
src: targetFrame.imageSrc,
objectKey: targetFrame.objectKey,
assetObjectId: targetFrame.assetObjectId,
},
)
);
});
}
function hasSameLayerContent(
current: CanvasLayer,
target: CanvasLayer,
): boolean {
return (
(current.mediaType ?? 'image') === (target.mediaType ?? 'image') &&
hasSameStableMediaReference(current, target) &&
hasSameImageSequenceFrames(current, target) &&
current.imageSequenceDurationMs === target.imageSequenceDurationMs
);
}
function cloneDialogPlaceholder(
dialog: GenerateDialogState,
): GenerateDialogState['placeholder'] {
return dialog.placeholder ? { ...dialog.placeholder } : undefined;
}
function mergeLayerForHistoryRestore(
target: CanvasLayer,
current: CanvasLayer | undefined,
restoreAssetKind: boolean,
restoreLayerSize: boolean,
): CanvasLayer {
if (!current) {
return { ...target };
}
const resourceAssetKind =
current.resourceAssetKind ?? target.resourceAssetKind;
const restoresLayerOverride =
target.assetKindOverride !== undefined ||
current.assetKindOverride !== undefined;
const assetKindOverride = restoreAssetKind
? (target.assetKindOverride ?? null)
: current.assetKindOverride;
return {
...current,
x: target.x,
y: target.y,
...(restoreLayerSize ? { width: target.width, height: target.height } : {}),
zIndex: target.zIndex,
groupId: target.groupId,
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
...(restoresLayerOverride ? { assetKindOverride } : {}),
assetKind: restoreAssetKind
? restoresLayerOverride
? (assetKindOverride ?? resourceAssetKind ?? null)
: target.assetKind
: current.assetKind,
hidden: target.hidden,
locked: target.locked,
flipX: target.flipX,
flipY: target.flipY,
};
}
function mergeDialogPlaceholderForHistoryRestore(
target: GenerateDialogState,
current: GenerateDialogState,
): GenerateDialogState['placeholder'] {
if (!current.placeholder) {
return cloneDialogPlaceholder(target);
}
if (!target.placeholder) {
return cloneDialogPlaceholder(current);
}
return {
...current.placeholder,
x: target.placeholder.x,
y: target.placeholder.y,
};
}
function mergeDialogForHistoryRestore<
TTarget extends GenerateDialogState,
TCurrent extends GenerateDialogState,
>(target: TTarget, current: TCurrent | undefined): TTarget | TCurrent {
if (!current) {
return {
...target,
placeholder: cloneDialogPlaceholder(target),
};
}
return {
...current,
composerOpen: target.composerOpen,
placeholder: mergeDialogPlaceholderForHistoryRestore(target, current),
};
}
export function mergeCanvasHistorySnapshotForRestore({
current,
target,
assetKindLayerIds,
layerSizeLayerIds,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
assetKindLayerIds?: ReadonlySet<string>;
layerSizeLayerIds?: ReadonlySet<string>;
}): CanvasHistorySnapshot {
const currentLayerById = new Map(
current.layers.map((layer) => [layer.id, layer] as const),
);
const currentDialogById = new Map<string, GenerateDialogState>();
for (const dialog of current.inactiveGenerateDialogs) {
currentDialogById.set(dialog.id, dialog);
}
if (current.generateDialog?.id) {
currentDialogById.set(current.generateDialog.id, current.generateDialog);
}
const mergeTargetDialog = <T extends GenerateDialogState>(dialog: T): T =>
mergeDialogForHistoryRestore(
dialog,
dialog.id ? currentDialogById.get(dialog.id) : undefined,
) as T;
return {
...target,
layers: target.layers.map((layer) =>
mergeLayerForHistoryRestore(
layer,
currentLayerById.get(layer.id),
assetKindLayerIds?.has(layer.id) ?? true,
layerSizeLayerIds?.has(layer.id) ?? false,
),
),
generateDialog: target.generateDialog
? mergeTargetDialog(target.generateDialog)
: null,
inactiveGenerateDialogs: target.inactiveGenerateDialogs.map(
(dialog): CanvasGenerationDialogState => mergeTargetDialog(dialog),
),
};
}
export function canRestoreCanvasHistorySnapshotWithoutContentLoss({
current,
target,
}: {
current: CanvasHistorySnapshot;
target: CanvasHistorySnapshot;
}): boolean {
const targetLayerById = new Map(
target.layers.map((layer) => [layer.id, layer] as const),
);
const preservesLayers = current.layers.every((currentLayer) => {
const targetLayer = targetLayerById.get(currentLayer.id);
if (!targetLayer) {
return false;
}
if (!currentLayer.hidden && targetLayer.hidden) {
return false;
}
return hasSameLayerContent(currentLayer, targetLayer);
});
if (!preservesLayers) {
return false;
}
const getDialogIds = (snapshot: CanvasHistorySnapshot): Set<string> =>
new Set(
[
snapshot.generateDialog?.id,
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
].filter((dialogId): dialogId is string => Boolean(dialogId)),
);
const targetDialogIds = getDialogIds(target);
return [...getDialogIds(current)].every((dialogId) =>
targetDialogIds.has(dialogId),
);
}
+12
View File
@@ -0,0 +1,12 @@
export * from './exportModel';
export * from './history';
export * from './interaction';
export * from './layerCommands';
export * from './layerTransform';
export * from './model';
export * from './overlays';
export * from './ports';
export * from './selection';
export * from './stageController';
export * from './stageInteractions';
export * from './types';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
import { getCanvasGenerationSelectionId } from './selection';
import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasLayer,
} from './types';
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,
},
);
}
@@ -0,0 +1,80 @@
import { clamp } from './model';
import type { CanvasLayer } from './types';
export type CanvasLayerResizeHandle =
| 'left'
| 'top'
| 'right'
| 'bottom'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
export type CanvasLayerTransformBounds = Pick<
CanvasLayer,
'x' | 'y' | 'width' | 'height'
>;
const MIN_LAYER_SIZE = 1;
export function resizeCanvasLayerBounds({
initial,
handle,
deltaX,
deltaY,
preserveAspectRatio,
minSize = MIN_LAYER_SIZE,
}: {
initial: CanvasLayerTransformBounds;
handle: CanvasLayerResizeHandle;
deltaX: number;
deltaY: number;
preserveAspectRatio: boolean;
minSize?: number;
}): CanvasLayerTransformBounds {
const safeMinSize = Math.max(MIN_LAYER_SIZE, minSize);
const movesLeft = handle.includes('left');
const movesRight = handle.includes('right');
const movesTop = handle.includes('top');
const movesBottom = handle.includes('bottom');
let width = initial.width + (movesRight ? deltaX : movesLeft ? -deltaX : 0);
let height = initial.height + (movesBottom ? deltaY : movesTop ? -deltaY : 0);
if (preserveAspectRatio) {
const ratio = initial.width / Math.max(initial.height, safeMinSize);
if (movesLeft || movesRight) {
height = width / Math.max(ratio, Number.EPSILON);
} else {
width = height * ratio;
}
}
width = clamp(width, safeMinSize, Number.MAX_SAFE_INTEGER);
height = clamp(height, safeMinSize, Number.MAX_SAFE_INTEGER);
return {
x: movesLeft ? initial.x + initial.width - width : initial.x,
y: movesTop ? initial.y + initial.height - height : initial.y,
width,
height,
};
}
export function transformCanvasLayers(
layers: CanvasLayer[],
transforms: ReadonlyMap<string, Partial<CanvasLayerTransformBounds>>,
) {
return layers.map((layer) => {
const transform = transforms.get(layer.id);
if (!transform || layer.locked) {
return layer;
}
return {
...layer,
x: transform.x ?? layer.x,
y: transform.y ?? layer.y,
width: Math.max(MIN_LAYER_SIZE, transform.width ?? layer.width),
height: Math.max(MIN_LAYER_SIZE, transform.height ?? layer.height),
};
});
}
+285
View File
@@ -0,0 +1,285 @@
import type { CanvasLayer, CanvasSnapItem, SnapCandidate } from './types';
export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.025;
export const MAX_SCALE = 3.2;
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
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 MAX_HISTORY_STEPS = 60;
export const SNAP_THRESHOLD_SCREEN_PX = 18;
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
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 formatCanvasDisplayScalePercent(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
}
export function canvasDisplayScaleToViewportScale(displayScale: number) {
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
}
export function viewportScaleToCanvasDisplayScale(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
}
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 resolveSnappedItemPosition(
movingItem: CanvasSnapItem,
proposedX: number,
proposedY: number,
items: CanvasSnapItem[],
scale: number,
) {
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
const visibleItems = items.filter(
(item) => item.id !== movingItem.id && !item.hidden,
);
const verticalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...visibleItems.flatMap((item) => [
item.x,
item.x + item.width / 2,
item.x + item.width,
]),
];
const horizontalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...visibleItems.flatMap((item) => [
item.y,
item.y + item.height / 2,
item.y + item.height,
]),
];
const xAlignmentSnap = findNearestSnap(
proposedX,
[0, movingItem.width / 2, movingItem.width],
verticalTargets,
threshold,
);
const yAlignmentSnap = findNearestSnap(
proposedY,
[0, movingItem.height / 2, movingItem.height],
horizontalTargets,
threshold,
);
const xDistributionSnap = findNearestEqualSpacingSnap({
axis: 'x',
proposedStart: proposedX,
proposedCrossStart: proposedY,
movingSize: movingItem.width,
movingCrossSize: movingItem.height,
items: visibleItems,
threshold,
});
const yDistributionSnap = findNearestEqualSpacingSnap({
axis: 'y',
proposedStart: proposedY,
proposedCrossStart: proposedX,
movingSize: movingItem.height,
movingCrossSize: movingItem.width,
items: visibleItems,
threshold,
});
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
return {
x: xSnap ? xSnap.position : proposedX,
y: ySnap ? ySnap.position : proposedY,
guide:
xSnap || ySnap
? { vertical: xSnap?.guide, horizontal: ySnap?.guide }
: null,
};
}
function chooseNearestSnap(
first: SnapCandidate | null,
second: SnapCandidate | null,
) {
if (!first) return second;
if (!second) return first;
return second.distance < first.distance ? second : first;
}
function findNearestEqualSpacingSnap({
axis,
proposedStart,
proposedCrossStart,
movingSize,
movingCrossSize,
items,
threshold,
}: {
axis: 'x' | 'y';
proposedStart: number;
proposedCrossStart: number;
movingSize: number;
movingCrossSize: number;
items: CanvasSnapItem[];
threshold: number;
}): SnapCandidate | null {
const orderedItems = items
.filter((item) =>
snapItemsOverlapOnCrossAxis(
proposedCrossStart,
movingCrossSize,
item,
axis,
),
)
.sort(
(firstItem, secondItem) =>
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
);
let nearest: SnapCandidate | null = null;
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
const firstItem = orderedItems[firstIndex];
if (!firstItem) continue;
for (
let secondIndex = firstIndex + 1;
secondIndex < orderedItems.length &&
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
secondIndex += 1
) {
const secondItem = orderedItems[secondIndex];
if (!secondItem) continue;
const firstStart = getSnapItemStart(firstItem, axis);
const firstEnd = getSnapItemEnd(firstItem, axis);
const secondStart = getSnapItemStart(secondItem, axis);
const secondEnd = getSnapItemEnd(secondItem, axis);
const pairGap = secondStart - firstEnd;
if (pairGap < 0) continue;
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstStart - pairGap - movingSize,
proposedStart,
movingSize,
threshold,
}),
);
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: secondEnd + pairGap,
proposedStart,
movingSize,
threshold,
}),
);
const betweenGap = (pairGap - movingSize) / 2;
if (betweenGap >= 0) {
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstEnd + betweenGap,
proposedStart,
movingSize,
threshold,
}),
);
}
}
}
return nearest;
}
function createDistributionSnapCandidate({
position,
proposedStart,
movingSize,
threshold,
}: {
position: number;
proposedStart: number;
movingSize: number;
threshold: number;
}): SnapCandidate | null {
const distance = Math.abs(position - proposedStart);
return distance > threshold
? null
: { position, guide: position + movingSize / 2, distance };
}
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.x : item.y;
}
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.width : item.height;
}
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
}
function snapItemsOverlapOnCrossAxis(
proposedCrossStart: number,
movingCrossSize: number,
item: CanvasSnapItem,
axis: 'x' | 'y',
) {
const itemCrossStart = axis === 'x' ? item.y : item.x;
const itemCrossSize = axis === 'x' ? item.height : item.width;
return (
proposedCrossStart <=
itemCrossStart + itemCrossSize - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
proposedCrossStart + movingCrossSize >=
itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
);
}
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 && (!nearest || distance < nearest.distance)) {
nearest = { position: target - offset, guide: target, distance };
}
}
}
return nearest;
}
+216
View File
@@ -0,0 +1,216 @@
import type {
CanvasGenerationDialogMode,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
CropExpandPanelState,
GenerateDialogState,
QuickEditPanelState,
} from './types';
type CanvasSize = {
width: number;
height: number;
};
type OverlayAnchor = Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
const QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN = 56;
const QUICK_EDIT_FOCUS_SOURCE_TOP = 56;
const QUICK_EDIT_PANEL_GAP = 12;
const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260;
const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4;
export type CanvasOverlayStyle = {
left: number;
top: number;
};
export type CanvasComposerOverlayStyle = CanvasOverlayStyle & {
width?: string;
};
export function resolveGenerationAnchor({
dialog,
generatedLayer,
}: {
dialog: CanvasGenerationDialogState | null;
generatedLayer: CanvasLayer | null;
}) {
if (!dialog) {
return null;
}
return generatedLayer ?? dialog.placeholder ?? null;
}
export function resolveGenerationComposerStyle({
dialog,
anchor,
viewport,
}: {
dialog: CanvasGenerationDialogState | null;
anchor: OverlayAnchor | null;
viewport: CanvasViewport;
}): CanvasOverlayStyle | null {
if (
!dialog ||
dialog.status === 'generating' ||
dialog.composerOpen === false ||
!anchor
) {
return null;
}
return {
left: viewport.x + (anchor.x + anchor.width / 2) * viewport.scale,
top: viewport.y + (anchor.y + anchor.height) * viewport.scale + 10,
};
}
export function resolveIconComposerStyle({
dialog,
composerStyle,
}: {
dialog: CanvasGenerationDialogState | null;
composerStyle: CanvasOverlayStyle | null;
}): CanvasComposerOverlayStyle | null {
if (dialog?.mode !== 'icon' || !composerStyle) {
return null;
}
return composerStyle;
}
export function resolveSelectedToolbarStyle({
selectedLayer,
viewport,
}: {
selectedLayer: CanvasLayer | null;
viewport: CanvasViewport;
canvasSize: CanvasSize;
}): CanvasOverlayStyle | null {
if (!selectedLayer) {
return null;
}
return {
left:
viewport.x +
selectedLayer.x * viewport.scale +
(selectedLayer.width * viewport.scale) / 2,
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
};
}
export function resolveQuickEditFocusViewport({
sourceLayer,
canvasSize,
}: {
sourceLayer: CanvasLayer;
canvasSize: CanvasSize;
}): CanvasViewport {
const safeSourceWidth = Math.max(1, sourceLayer.width);
const safeSourceHeight = Math.max(1, sourceLayer.height);
const availableWidth = Math.max(
1,
canvasSize.width - QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN * 2,
);
const availableHeight = Math.max(
1,
canvasSize.height -
QUICK_EDIT_FOCUS_SOURCE_TOP -
QUICK_EDIT_PANEL_GAP -
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
);
const scale = Math.min(
1,
availableWidth / safeSourceWidth,
availableHeight / safeSourceHeight,
);
return {
scale,
x: canvasSize.width / 2 - (sourceLayer.x + sourceLayer.width / 2) * scale,
y: QUICK_EDIT_FOCUS_SOURCE_TOP - sourceLayer.y * scale,
};
}
export function resolveQuickEditPanelStyle({
panel,
sourceLayer,
viewport,
}: {
panel: QuickEditPanelState | null;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
canvasSize: CanvasSize;
}): CanvasOverlayStyle | null {
if (!panel || !sourceLayer) {
return null;
}
return {
left: viewport.x + (sourceLayer.x + sourceLayer.width / 2) * viewport.scale,
top:
viewport.y +
(sourceLayer.y + sourceLayer.height) * viewport.scale +
QUICK_EDIT_PANEL_GAP,
};
}
export function resolveCropExpandPanelStyle({
panel,
sourceLayer,
viewport,
}: {
panel: CropExpandPanelState | null;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
canvasSize: CanvasSize;
}): CanvasOverlayStyle | null {
if (!panel || !sourceLayer) {
return null;
}
return {
left:
viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12,
top: viewport.y + sourceLayer.y * viewport.scale,
};
}
export function resolveCharacterAnimationPanelStyle({
panel,
sourceLayer,
viewport,
}: {
panel: CharacterAnimationPanelState | null;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
canvasSize: CanvasSize;
}): CanvasOverlayStyle | null {
if (!panel || !sourceLayer) {
return null;
}
return {
left:
viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12,
top: viewport.y + sourceLayer.y * viewport.scale,
};
}
export function isCanvasGenerationComposerVisible(
dialog: GenerateDialogState | null,
): dialog is GenerateDialogState & { mode: CanvasGenerationDialogMode } {
return (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'publication' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'audio-sound-effect' ||
dialog?.mode === 'audio-background-music'
);
}
+304
View File
@@ -0,0 +1,304 @@
export type ImageCanvasHostKind = 'web' | 'tauri';
export type ImageCanvasHostCapabilities = {
account: boolean;
wallet: boolean;
cloudAssetLibrary: boolean;
localProject: boolean;
externalEditorGeneration: boolean;
advancedBackgroundRemoval: boolean;
};
export type ImageCanvasHostScope = {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
export type ImageCanvasHostResult<T> =
| { status: 'ok'; value: T }
| {
status: 'unsupported-capability';
capability: keyof ImageCanvasHostCapabilities;
message: string;
}
| {
status: 'conflict';
conflictKind: 'project-identity' | 'host-revision' | 'draft-revision';
draft: ImageCanvasDraft | null;
hostRevision: string | null;
}
| { status: 'failed'; code: string; message: string };
export type ImageCanvasMediaRef =
| { kind: 'project-asset'; assetId: string }
| {
kind: 'draft-media';
mediaId: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
sha256: string;
byteLength: number;
pixelWidth: number;
pixelHeight: number;
}
| { kind: 'host-resource'; resourceId: string };
export type ImageCanvasDraftLayer = {
layerId: string;
resourceId: string;
title: string;
mediaRef: ImageCanvasMediaRef;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
groupId: string | null;
hidden: boolean;
locked: boolean;
flipX: boolean;
flipY: boolean;
};
export type ImageCanvasDraftCanvas = {
viewport: { x: number; y: number; scale: number };
backgroundColor: string;
layers: ImageCanvasDraftLayer[];
selectedLayerIds: string[];
primarySelectedLayerId: string | null;
};
export type ImageCanvasDraft = {
schemaVersion: 'game-creator-asset-canvas-draft.v1';
draftId: string;
projectId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
sourceResourceId: string | null;
revision: number;
status:
| 'editing'
| 'generating'
| 'commit-prepared'
| 'committed'
| 'cancelled'
| 'reconciliation-required';
canvas: ImageCanvasDraftCanvas;
generations: ImageCanvasGenerationRecord[];
pendingCommit: {
commitId: string;
idempotencyKey?: string;
requestFingerprint: string;
} | null;
lastCommit: {
commitId: string;
idempotencyKey?: string;
assetId: string;
eventId: string;
committedProjectRevision: number;
} | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationRecord = {
generationId: string;
intentId: string;
phase:
| 'generation-accepted'
| 'generation-running'
| 'remote-completed'
| 'media-downloaded'
| 'asset-durable-committed'
| 'failed'
| 'reconciliation-required';
referenceResourceIds: string[];
outputAssetId: string | null;
errorCode: string | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationProgressPhase =
| 'confirmation-required'
| ImageCanvasGenerationRecord['phase']
| 'manifest-projected'
| 'layout-ready'
| 'selected';
export type ImageCanvasGenerationProgress = {
intentId: string;
generationId: string;
draftRevision?: number;
phase: ImageCanvasGenerationProgressPhase;
progress: number | null;
errorCode: string | null;
};
export type ImageCanvasGenerationServiceIdentityConfirmation = {
generationId: string;
operationId: string | null;
operationState: string;
serviceOrigin: string;
challenge: string;
expiresAt: number;
};
export type ImageCanvasGenerationCommitResult = {
resourceId: string;
assetId: string;
projectId: string;
commitId: string;
committedProjectRevision: number;
draftRevision: number;
hostRevision: string;
commitStatus: 'committed' | 'already-committed';
manifest: unknown;
eventId: string;
};
export type ImageCanvasHostInputImage = {
name: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
bytes: Uint8Array;
};
export type ImageCanvasHostImage = {
mediaRef: ImageCanvasMediaRef;
previewUrl: string;
resourceId: string | null;
};
export interface ImageCanvasProjectPort {
loadDraft(
scope: ImageCanvasHostScope,
): Promise<ImageCanvasHostResult<ImageCanvasDraft | null>>;
createDraft(
scope: ImageCanvasHostScope,
): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
updateDraft(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
status: 'editing' | 'generating' | 'cancelled';
canvas: ImageCanvasDraft['canvas'];
generations: ImageCanvasGenerationRecord[];
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
discardDraft(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
}
export interface ImageCanvasAssetPort {
importImages(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
images: ImageCanvasHostInputImage[];
}): Promise<ImageCanvasHostResult<ImageCanvasHostImage[]>>;
exportImage(input: {
scope: ImageCanvasHostScope;
name: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
quality: number | null;
bytes: Uint8Array;
}): Promise<
ImageCanvasHostResult<{
disposition: 'downloaded' | 'saved';
byteLength: number;
}>
>;
}
export interface ImageCanvasGenerationPort {
generateImage(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
intentId: string;
generationId: string;
idempotencyKey: string;
commitId: string;
commitIdempotencyKey: string;
prompt: string;
aspectRatio: '1:1' | '2:3' | '3:2' | '9:16' | '16:9';
imageSize: '0.5K' | '1K' | '2K';
assetKind: string;
assetName: string;
referenceResourceIds: string[];
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<
ImageCanvasHostResult<{
generation: ImageCanvasGenerationRecord;
images: ImageCanvasHostImage[];
commit: ImageCanvasGenerationCommitResult;
}>
>;
recoverImages(input: {
scope: ImageCanvasHostScope;
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<
ImageCanvasHostResult<{
resumedGenerationIds: string[];
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
}>
>;
removeBackground(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;
idempotencyKey: string;
sourceLayerId: string;
}): Promise<
ImageCanvasHostResult<{
generation: ImageCanvasGenerationRecord;
images: ImageCanvasHostImage[];
}>
>;
}
export interface ImageCanvasCompletionPort {
commitImage(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
commitId: string;
idempotencyKey: string;
name: string;
assetKind: string;
referenceResourceIds: string[];
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
bytes: Uint8Array;
}): Promise<
ImageCanvasHostResult<{
resourceId: string;
assetId?: string;
projectId?: string;
commitId?: string;
committedProjectRevision?: number;
draftRevision: number;
hostRevision: string;
commitStatus?: 'committed' | 'already-committed';
manifest?: unknown;
eventId?: string;
}>
>;
}
export type ImageCanvasHostPort = {
readonly kind: ImageCanvasHostKind;
readonly capabilities: ImageCanvasHostCapabilities;
project: ImageCanvasProjectPort;
asset: ImageCanvasAssetPort;
generation: ImageCanvasGenerationPort;
completion: ImageCanvasCompletionPort;
};
export function unsupportedImageCanvasCapability(
capability: keyof ImageCanvasHostCapabilities,
message: string,
): ImageCanvasHostResult<never> {
return { status: 'unsupported-capability', capability, message };
}
@@ -0,0 +1,84 @@
import type { CanvasGenerationDialogState, CanvasLayer } from './types';
export const CANVAS_GENERATION_SELECTION_PREFIX = 'generation-dialog:';
export function getCanvasGenerationSelectionId(dialogId: string) {
return `${CANVAS_GENERATION_SELECTION_PREFIX}${dialogId}`;
}
export function getCanvasGenerationDialogIdFromSelectionId(targetId: string) {
return targetId.startsWith(CANVAS_GENERATION_SELECTION_PREFIX)
? targetId.slice(CANVAS_GENERATION_SELECTION_PREFIX.length)
: null;
}
export function isCanvasGenerationSelectionId(targetId: string) {
return getCanvasGenerationDialogIdFromSelectionId(targetId) !== null;
}
export function getSelectedLayerIds(selectionIds: string[]) {
return selectionIds.filter(
(selectionId) => !isCanvasGenerationSelectionId(selectionId),
);
}
export function getSelectedGenerationDialogIds(selectionIds: string[]) {
return selectionIds.flatMap((selectionId) => {
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
return dialogId ? [dialogId] : [];
});
}
export function dedupeSelectionIds(selectionIds: string[]) {
return Array.from(new Set(selectionIds));
}
export function firstSelectedLayerId(selectionIds: string[]) {
return getSelectedLayerIds(selectionIds)[0] ?? null;
}
export function normalizeCanvasSelectionIds({
selectionIds,
layers,
generationDialogs,
}: {
selectionIds: string[];
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
}) {
const layerIds = new Set(layers.map((layer) => layer.id));
const generationDialogIds = new Set(
generationDialogs.map((dialog) => dialog.id),
);
return dedupeSelectionIds(selectionIds).filter((selectionId) => {
if (isCanvasGenerationSelectionId(selectionId)) {
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
return Boolean(dialogId && generationDialogIds.has(dialogId));
}
return layerIds.has(selectionId);
});
}
export function resolvePointerSelection({
targetId,
selectedIds,
isMultiSelectGesture,
}: {
targetId: string;
selectedIds: string[];
isMultiSelectGesture: boolean;
}) {
if (!isMultiSelectGesture) {
if (selectedIds.includes(targetId)) {
return selectedIds;
}
return [targetId];
}
if (!selectedIds.includes(targetId)) {
return [...selectedIds, targetId];
}
if (selectedIds.length <= 1) {
return [targetId];
}
return selectedIds.filter((selectedId) => selectedId !== targetId);
}
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
type CanvasLayer,
createCanvasExportModel,
createMinimapModel,
fitViewportToLayers,
getWorldPointFromClient,
moveLayersFromDrag,
panViewportFromWheel,
resizeCanvasLayerBounds,
resolvePointerSelection,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
selectLayersInsideMarquee,
transformCanvasLayers,
} from './index';
function layer(id: string, overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `media:${id}`,
x: 0,
y: 0,
width: 100,
height: 80,
originalWidth: 100,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
describe('shared image canvas core', () => {
it('keeps the world anchor stable while zooming and converts coordinates', () => {
const viewport = { x: 20, y: 30, scale: 0.5 };
const anchor = { x: 240, y: 180 };
const before = getWorldPointFromClient({
clientX: anchor.x,
clientY: anchor.y,
rect: { left: 0, top: 0, right: 800, bottom: 600 },
viewport,
});
const next = scaleViewportFromScreenPoint({
viewport,
nextScale: 1.25,
screenPoint: anchor,
});
const after = getWorldPointFromClient({
clientX: anchor.x,
clientY: anchor.y,
rect: { left: 0, top: 0, right: 800, bottom: 600 },
viewport: next,
});
expect(after).toEqual(before);
expect(panViewportFromWheel({ viewport, deltaX: 12, deltaY: -8 })).toEqual({
x: 8,
y: 38,
scale: 0.5,
});
});
it('shares pan, shift-pan and modifier-zoom wheel semantics', () => {
const viewport = { x: 20, y: 30, scale: 1 };
expect(
resolveViewportFromWheel({
viewport,
deltaX: 4,
deltaY: 10,
shiftKey: false,
ctrlKey: false,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}),
).toEqual({ mode: 'pan', viewport: { x: 16, y: 20, scale: 1 } });
expect(
resolveViewportFromWheel({
viewport,
deltaX: 0,
deltaY: 10,
shiftKey: true,
ctrlKey: false,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}),
).toEqual({ mode: 'pan', viewport: { x: 10, y: 30, scale: 1 } });
expect(
resolveViewportFromWheel({
viewport,
deltaX: 0,
deltaY: -10,
shiftKey: false,
ctrlKey: true,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}).mode,
).toBe('zoom');
});
it('supports additive selection, marquee and multi-layer movement', () => {
expect(
resolvePointerSelection({
targetId: 'b',
selectedIds: ['a'],
isMultiSelectGesture: true,
}),
).toEqual(['a', 'b']);
expect(
resolvePointerSelection({
targetId: 'a',
selectedIds: ['a'],
isMultiSelectGesture: true,
}),
).toEqual(['a']);
expect(
resolvePointerSelection({
targetId: 'a',
selectedIds: ['a', 'b'],
isMultiSelectGesture: true,
}),
).toEqual(['b']);
const layers = [
layer('a', { x: 10, y: 10 }),
layer('b', { x: 140, y: 20 }),
];
expect(
selectLayersInsideMarquee({
layers,
marquee: {
pointerId: 1,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
},
currentPoint: { x: 260, y: 120 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toEqual(['a', 'b']);
const moved = moveLayersFromDrag({
layers,
dragState: {
kind: 'layer',
pointerId: 1,
layerId: 'a',
layerIds: ['a', 'b'],
startClientX: 0,
startClientY: 0,
startLayerX: 10,
startLayerY: 10,
startLayers: [
{ id: 'a', x: 10, y: 10 },
{ id: 'b', x: 140, y: 20 },
],
startScale: 1,
},
pointer: { x: 20, y: 30 },
});
expect(moved.layers.map(({ x, y }) => ({ x, y }))).toEqual([
{ x: 30, y: 40 },
{ x: 160, y: 50 },
]);
});
it('resizes layers and preserves locked layers during transforms', () => {
expect(
resizeCanvasLayerBounds({
initial: { x: 10, y: 20, width: 200, height: 100 },
handle: 'top-left',
deltaX: -40,
deltaY: -20,
preserveAspectRatio: true,
}),
).toEqual({ x: -30, y: 0, width: 240, height: 120 });
const locked = layer('locked', { locked: true });
expect(
transformCanvasLayers(
[locked],
new Map([['locked', { x: 99, width: 300 }]]),
)[0],
).toBe(locked);
});
it('fits content, projects the minimap and creates a host-neutral export', () => {
const layers = [
layer('a', { x: 100, y: 200 }),
layer('b', { x: 400, y: 500, width: 200, height: 120, zIndex: 2 }),
];
const viewport = fitViewportToLayers({
layers,
canvasSize: { width: 900, height: 640 },
});
expect(viewport).not.toBeNull();
expect(
createMinimapModel({
layers,
viewport: viewport!,
canvasSize: { width: 900, height: 640 },
})?.layers,
).toHaveLength(2);
expect(
createCanvasExportModel({ layers, backgroundColor: '#ffffff' }),
).toMatchObject({
bounds: { x: 100, y: 200, width: 500, height: 420 },
backgroundColor: '#ffffff',
});
});
});
@@ -0,0 +1,208 @@
import {
getCanvasLayersByIds,
resolveContextTargetLayerIds,
} from './layerCommands';
import { resolveSelectedToolbarStyle } from './overlays';
import {
getSelectedGenerationDialogIds,
getSelectedLayerIds,
} from './selection';
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
} from './types';
const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
const CONTEXT_MENU_SIZE = {
blank: { width: 188, height: 176 },
layer: { width: 188, height: 492 },
'generation-dialog': { width: 188, height: 64 },
} as const;
export type CanvasViewportBounds = { width: number; height: number };
export function resolveContextMenuPosition(
clientX: number,
clientY: number,
kind: CanvasContextMenuState['kind'],
viewportBounds?: CanvasViewportBounds,
) {
if (!viewportBounds) {
return { x: clientX, y: clientY };
}
const menuSize = CONTEXT_MENU_SIZE[kind];
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
return {
x: clamp(
clientX,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
viewportBounds.width - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN,
),
),
y: clamp(
clientY,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
viewportBounds.height - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN,
),
),
};
}
export type ImageCanvasStageControllerModel = {
selectedLayer: CanvasLayer | null;
selectedLayerCount: number;
hasMultipleSelectedLayers: boolean;
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
imageContextMenuLayer: CanvasLayer | null;
contextTargetIds: string[];
contextTargetLayers: CanvasLayer[];
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
};
type ResolveImageCanvasStageControllerModelOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
imageContextMenu: ImageContextMenuState | null;
contextMenu: CanvasContextMenuState | null;
viewport: CanvasViewport;
canvasSize: { width: number; height: number };
};
export function resolveImageCanvasStageControllerModel({
layers,
canvasGenerationDialogs = [],
selectedLayerId,
selectedLayerIds,
imageContextMenu,
contextMenu,
viewport,
canvasSize,
}: ResolveImageCanvasStageControllerModelOptions): ImageCanvasStageControllerModel {
const selectedLayer =
layers.find((layer) => layer.id === selectedLayerId) ?? null;
const selectedCanvasLayerIds = getSelectedLayerIds(selectedLayerIds);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(selectedLayerIds);
const selectedLayerCount =
selectedCanvasLayerIds.filter((layerId) =>
layers.some((layer) => layer.id === layerId),
).length +
selectedGenerationDialogIds.filter((dialogId) =>
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
).length;
const imageContextMenuLayer = imageContextMenu
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
: null;
const contextTargetIds = resolveContextTargetLayerIds(
contextMenu,
selectedLayerIds,
);
const contextTargetLayers = getCanvasLayersByIds(layers, contextTargetIds);
return {
selectedLayer,
selectedLayerCount,
hasMultipleSelectedLayers: selectedLayerCount > 1,
selectedToolbarStyle: resolveSelectedToolbarStyle({
selectedLayer,
viewport,
canvasSize,
}),
imageContextMenuLayer,
contextTargetIds,
contextTargetLayers,
contextShouldShowLayer: contextTargetLayers.some((layer) => layer.hidden),
contextShouldUnlockLayer: contextTargetLayers.some((layer) => layer.locked),
};
}
export function createBlankCanvasContextMenu({
clientX,
clientY,
canvasPoint,
viewportBounds,
}: {
clientX: number;
clientY: number;
canvasPoint: { x: number; y: number };
viewportBounds?: CanvasViewportBounds;
}): CanvasContextMenuState {
return {
kind: 'blank',
...resolveContextMenuPosition(clientX, clientY, 'blank', viewportBounds),
canvasPoint,
};
}
export function createLayerCanvasContextMenus({
clientX,
clientY,
layerId,
canvasPoint,
viewportBounds,
}: {
clientX: number;
clientY: number;
layerId: string;
canvasPoint: { x: number; y: number };
viewportBounds?: CanvasViewportBounds;
}): {
contextMenu: CanvasContextMenuState;
imageContextMenu: ImageContextMenuState;
} {
const position = resolveContextMenuPosition(
clientX,
clientY,
'layer',
viewportBounds,
);
return {
contextMenu: {
kind: 'layer',
layerId,
...position,
canvasPoint,
},
imageContextMenu: {
layerId,
...position,
},
};
}
export function createGenerationDialogCanvasContextMenu({
clientX,
clientY,
dialogId,
canvasPoint,
viewportBounds,
}: {
clientX: number;
clientY: number;
dialogId: string;
canvasPoint: { x: number; y: number };
viewportBounds?: CanvasViewportBounds;
}): CanvasContextMenuState {
return {
kind: 'generation-dialog',
dialogId,
...resolveContextMenuPosition(
clientX,
clientY,
'generation-dialog',
viewportBounds,
),
canvasPoint,
};
}
@@ -0,0 +1,440 @@
import type { CanvasPoint } from './interaction';
import {
firstSelectedLayerId,
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
getSelectedLayerIds,
resolvePointerSelection,
} from './selection';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
DragState,
GenerateDialogState,
} from './types';
type PointerSource = {
button?: number;
buttons?: number;
clientX?: number;
clientY?: number;
pointerId?: number;
nativeEvent?: {
button?: number;
buttons?: number;
clientX?: number;
clientY?: number;
pointerId?: number;
};
};
type CanvasRectLike =
| {
left?: number;
top?: number;
}
| null
| undefined;
const CANVAS_GENERATION_DIALOG_MODES = new Set([
'generate',
'scene',
'spec',
'character',
'icon',
'ui-design',
'character-animation',
'video',
'audio-sound-effect',
'audio-background-music',
]);
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function hasCanvasGenerationDialogMode(
dialog: GenerateDialogState | null,
): dialog is GenerateDialogState & {
mode: CanvasGenerationDialogState['mode'];
} {
if (!dialog) {
return false;
}
return CANVAS_GENERATION_DIALOG_MODES.has(dialog.mode);
}
export function getPointerButton(event: PointerSource) {
const nativeButtons = Number(event.nativeEvent?.buttons);
if (Number.isFinite(nativeButtons)) {
if ((nativeButtons & 2) === 2) {
return 2;
}
if ((nativeButtons & 4) === 4) {
return 1;
}
}
const syntheticButtons = Number(event.buttons);
if (Number.isFinite(syntheticButtons)) {
if ((syntheticButtons & 2) === 2) {
return 2;
}
if ((syntheticButtons & 4) === 4) {
return 1;
}
}
const syntheticButton = Number(event.button);
if (Number.isFinite(syntheticButton)) {
return syntheticButton;
}
const nativeButton = Number(event.nativeEvent?.button);
if (Number.isFinite(nativeButton)) {
return nativeButton;
}
return 0;
}
export function getPointerClient(event: PointerSource): CanvasPoint {
return {
x: isFiniteNumber(event.clientX)
? event.clientX
: isFiniteNumber(event.nativeEvent?.clientX)
? event.nativeEvent.clientX
: 0,
y: isFiniteNumber(event.clientY)
? event.clientY
: isFiniteNumber(event.nativeEvent?.clientY)
? event.nativeEvent.clientY
: 0,
};
}
export function getPointerId(event: PointerSource) {
return isFiniteNumber(event.pointerId)
? event.pointerId
: isFiniteNumber(event.nativeEvent?.pointerId)
? event.nativeEvent.pointerId
: -1;
}
export function getCanvasPointFromPointer({
pointer,
rect,
}: {
pointer: CanvasPoint;
rect: CanvasRectLike;
}): CanvasPoint {
return {
x: pointer.x - (rect?.left ?? 0),
y: pointer.y - (rect?.top ?? 0),
};
}
export function createCanvasMarqueeState({
pointerId,
pointer,
rect,
}: {
pointerId: number;
pointer: CanvasPoint;
rect: CanvasRectLike;
}): CanvasMarqueeState {
const startPoint = getCanvasPointFromPointer({ pointer, rect });
return {
pointerId,
startX: startPoint.x,
startY: startPoint.y,
currentX: startPoint.x,
currentY: startPoint.y,
};
}
export function createPanDragState({
pointerId,
pointer,
viewport,
}: {
pointerId: number;
pointer: CanvasPoint;
viewport: CanvasViewport;
}): Extract<DragState, { kind: 'pan' }> {
return {
kind: 'pan',
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startViewport: viewport,
};
}
export function resolveLayerPointerSelection({
layerId,
selectedLayerIds,
isMultiSelectGesture,
}: {
layerId: string;
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
}) {
return resolvePointerSelection({
targetId: layerId,
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
}
export function createLayerDragStart({
layer,
layers,
generationDialogs = [],
selectedLayerIds,
isMultiSelectGesture,
pointerId,
pointer,
viewportScale,
}: {
layer: CanvasLayer;
layers: CanvasLayer[];
generationDialogs?: CanvasGenerationDialogState[];
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): {
selectedLayerIds: string[];
selectedLayerId: string | null;
dragState: Extract<DragState, { kind: 'layer' }>;
} {
const nextSelectedLayerIds = resolveLayerPointerSelection({
layerId: layer.id,
selectedLayerIds,
isMultiSelectGesture,
});
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const dragLayerIds = selectedCanvasLayerIds.includes(layer.id)
? selectedCanvasLayerIds
: [layer.id];
const startLayers = layers
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
.map((currentLayer) => ({
id: currentLayer.id,
x: currentLayer.x,
y: currentLayer.y,
}));
const startGenerationFrames = generationDialogs
.filter(
(dialog) =>
selectedGenerationDialogIds.includes(dialog.id) && dialog.placeholder,
)
.map((dialog) => ({
id: dialog.id,
x: dialog.placeholder?.x ?? 0,
y: dialog.placeholder?.y ?? 0,
}));
return {
selectedLayerIds: nextSelectedLayerIds,
selectedLayerId: nextSelectedLayerIds.includes(layer.id)
? layer.id
: firstSelectedLayerId(nextSelectedLayerIds),
dragState: {
kind: 'layer',
pointerId,
layerId: layer.id,
layerIds: dragLayerIds,
generationDialogIds: selectedGenerationDialogIds,
startClientX: pointer.x,
startClientY: pointer.y,
startLayerX: layer.x,
startLayerY: layer.y,
startLayers,
startGenerationFrames,
startScale: viewportScale,
},
};
}
export function updateGenerateDialogForLayerPointerDown(
dialog: GenerateDialogState | null,
_layerId: string,
): GenerateDialogState | null {
if (!hasCanvasGenerationDialogMode(dialog)) {
return dialog;
}
return {
...dialog,
composerOpen: false,
};
}
export function updateGenerateDialogForLayerClick(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
if (!hasCanvasGenerationDialogMode(dialog)) {
return dialog;
}
return {
...dialog,
composerOpen: false,
};
}
export function createGenerationFrameSelectionStart({
dialog,
layers,
generationDialogs,
selectedLayerIds,
isMultiSelectGesture,
pointerId,
pointer,
viewportScale,
}: {
dialog: CanvasGenerationDialogState;
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
selectedLayerIds: string[];
isMultiSelectGesture: boolean;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): {
selectedLayerIds: string[];
selectedLayerId: string | null;
dragState: Extract<DragState, { kind: 'generation-frame' }> | null;
} {
const targetSelectionId = getCanvasGenerationSelectionId(dialog.id);
const nextSelectedLayerIds = resolvePointerSelection({
targetId: targetSelectionId,
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const dragDialogIds = selectedGenerationDialogIds.includes(dialog.id)
? selectedGenerationDialogIds
: [dialog.id];
const startFrames = generationDialogs
.filter(
(currentDialog) =>
dragDialogIds.includes(currentDialog.id) && currentDialog.placeholder,
)
.map((currentDialog) => ({
id: currentDialog.id,
x: currentDialog.placeholder?.x ?? 0,
y: currentDialog.placeholder?.y ?? 0,
}));
const startLayers = layers
.filter((currentLayer) => selectedCanvasLayerIds.includes(currentLayer.id))
.map((currentLayer) => ({
id: currentLayer.id,
x: currentLayer.x,
y: currentLayer.y,
}));
return {
selectedLayerIds: nextSelectedLayerIds,
selectedLayerId: firstSelectedLayerId(nextSelectedLayerIds),
dragState: createGenerationFrameDragState({
dialog,
dialogIds: dragDialogIds,
layerIds: selectedCanvasLayerIds,
startFrames,
startLayers,
pointerId,
pointer,
viewportScale,
}),
};
}
export function createGenerationFrameDragState({
dialog,
dialogIds = [dialog.id],
layerIds = [],
startFrames,
startLayers = [],
pointerId,
pointer,
viewportScale,
}: {
dialog: CanvasGenerationDialogState;
dialogIds?: string[];
layerIds?: string[];
startFrames?: Array<{ id: string; x: number; y: number }>;
startLayers?: Array<{ id: string; x: number; y: number }>;
pointerId: number;
pointer: CanvasPoint;
viewportScale: number;
}): Extract<DragState, { kind: 'generation-frame' }> | null {
if (!dialog.placeholder) {
return null;
}
return {
kind: 'generation-frame',
dialogId: dialog.id,
dialogIds,
layerIds,
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startFrameX: dialog.placeholder.x,
startFrameY: dialog.placeholder.y,
startFrames:
startFrames && startFrames.length
? startFrames
: [
{
id: dialog.id,
x: dialog.placeholder.x,
y: dialog.placeholder.y,
},
],
startLayers,
startScale: viewportScale,
};
}
export function createMinimapDragState({
pointerId,
pointer,
viewport,
minimapScale,
}: {
pointerId: number;
pointer: CanvasPoint;
viewport: CanvasViewport;
minimapScale: number;
}): Extract<DragState, { kind: 'minimap' }> {
return {
kind: 'minimap',
pointerId,
startClientX: pointer.x,
startClientY: pointer.y,
startViewport: { ...viewport },
minimapScale,
moved: false,
};
}
export function updateMinimapDragMovement(
dragState: Extract<DragState, { kind: 'minimap' }>,
pointer: CanvasPoint,
): Extract<DragState, { kind: 'minimap' }> {
if (dragState.moved) {
return dragState;
}
const deltaX = pointer.x - dragState.startClientX;
const deltaY = pointer.y - dragState.startClientY;
return Math.hypot(deltaX, deltaY) >= 2
? {
...dragState,
moved: true,
}
: dragState;
}
+393
View File
@@ -0,0 +1,393 @@
import type { EditorSoundEffectGenerationMetadataV2 } from '../../shared/src/contracts/editorAudio';
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind =
| 'audio'
| 'spec'
| 'character'
| 'character-animation'
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'publication-material'
| 'ui-design'
| 'video'
| 'sound-effect'
| 'background-music'
| 'scene';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
export type CanvasGenerationAction =
| 'image.generate'
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'character-sheet.generate'
| 'icon.generate'
| 'ui-design.generate'
| 'publication.generate'
| 'video.generate'
| 'audio.sound-effect.generate'
| 'audio.background-music.generate'
| 'character-animation.generate'
| 'character-animation.convert'
| 'character-animation.remove-background'
| 'image.edit'
| 'ui-design.extract-assets'
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand';
export type CanvasGenerationInputValue = string | number | boolean;
export type CanvasGenerationInputField = {
id?: string;
title: string;
value: CanvasGenerationInputValue;
};
export type CanvasGenerationInputReference = {
id?: string;
title: string;
label?: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type CanvasGenerationInputs = {
version?: 2;
action?: CanvasGenerationAction;
fields: CanvasGenerationInputField[];
references: CanvasGenerationInputReference[];
soundEffect?: EditorSoundEffectGenerationMetadataV2;
};
export type CanvasGenerationInputsHydrationState =
| 'absent'
| 'valid'
| 'invalid-versioned';
export type CanvasSequenceFrame = {
frameIndex?: number;
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
};
/**
* The host-neutral layer model. Host adapters may attach additional metadata;
* canvas commands preserve it through object spreads without interpreting it.
*/
export type CanvasLayer = {
id: string;
resourceId: string;
resourcePersistenceState?:
| 'pending'
| 'registered'
| 'self-contained-local'
| 'unresolved-local';
title: string;
src: string;
mediaType?: CanvasMediaType;
thumbnailSrc?: string | null;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
sourceType: CanvasSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
groupId?: string | null;
resourceAssetKind?: CanvasAssetKind | null;
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
durationSeconds?: number;
imageSequenceFrames?: CanvasSequenceFrame[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
flipY?: boolean;
};
export type CanvasViewport = {
x: number;
y: number;
scale: number;
};
export type CanvasTool =
| 'select'
| 'hand'
| 'upload'
| 'generate'
| 'video'
| 'character-animation'
| 'music'
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'ui-design'
| 'scene';
export type CanvasGenerationDialogMode =
| 'generate'
| 'scene'
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'ui-design'
| 'quick-edit'
| 'character-animation'
| 'video'
| 'audio-sound-effect'
| 'audio-background-music';
export type GenerateDialogState = {
id?: string;
mode: CanvasGenerationDialogMode | 'edit';
prompt: string;
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
composerOpen?: boolean;
generatedLayerId?: string;
sourceLayerId?: string;
assetLabel?: string;
assetKind?: CanvasAssetKind | null;
errorMessage?: string;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
};
[hostMetadata: string]: unknown;
};
export type QuickEditPanelState = {
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
size: string;
model: string;
status: 'idle' | 'generating' | 'failed';
errorMessage?: string;
[hostMetadata: string]: unknown;
};
export type CropExpandPanelState = {
sourceLayerId: string;
frame: { x: number; y: number; width: number; height: number };
ratio: string;
status: 'idle' | 'processing' | 'failed';
errorMessage?: string;
};
export type CharacterAnimationPanelState = {
sourceLayerId?: string;
promptText: string;
status: 'idle' | 'generating' | 'completed' | 'failed';
errorMessage?: string;
[hostMetadata: string]: unknown;
};
export type CanvasGenerationDialogState = GenerateDialogState & {
id: string;
mode: CanvasGenerationDialogMode;
};
export type ImageContextMenuState = {
layerId: string;
x: number;
y: number;
};
export type CanvasHistorySnapshot = {
layers: CanvasLayer[];
viewport: CanvasViewport;
generateDialog: GenerateDialogState | null;
inactiveGenerateDialogs: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
};
export type CanvasHistoryActionType =
| 'move-image'
| 'resize-image'
| 'move-generation-result'
| 'delete-image'
| 'delete-generation-result'
| 'cut-image'
| 'paste-image'
| 'duplicate-image'
| 'add-image'
| 'upload-image'
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'perfect-pixel'
| 'split-atlas'
| 'replace-image'
| 'show-image'
| 'hide-image'
| 'change-layer-order'
| 'group-images'
| 'ungroup-images'
| 'lock-image'
| 'unlock-image'
| 'flip-image'
| 'change-asset-kind'
| 'change-viewport';
export type CanvasHistoryAction = {
type: CanvasHistoryActionType;
count?: number;
layerIds?: string[];
};
export type CanvasHistoryEntry = {
entryId: number;
snapshot: CanvasHistorySnapshot;
action: CanvasHistoryAction;
createdAt: number;
};
export type CanvasHistoryApplyResult =
| { status: 'success'; action: CanvasHistoryAction }
| {
status: 'blocked';
action: CanvasHistoryAction;
reason: 'content-may-disappear';
}
| { status: 'empty' };
export type CanvasClipboard = {
layers: CanvasLayer[];
mode: 'copy' | 'cut';
};
export type CanvasContextMenuState =
| {
kind: 'blank';
x: number;
y: number;
canvasPoint: { x: number; y: number };
}
| {
kind: 'layer';
x: number;
y: number;
layerId: string;
canvasPoint: { x: number; y: number };
}
| {
kind: 'generation-dialog';
x: number;
y: number;
dialogId: string;
canvasPoint: { x: number; y: number };
};
export type SnapGuide = {
vertical?: number;
horizontal?: number;
};
export type SnapCandidate = {
position: number;
guide: number;
distance: number;
};
export type CanvasSnapItem = {
id: string;
x: number;
y: number;
width: number;
height: number;
hidden?: boolean;
};
export type CanvasMarqueeState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
};
export type DragState =
| {
kind: 'pan';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
}
| {
kind: 'layer';
pointerId: number;
layerId: string;
layerIds: string[];
generationDialogIds?: string[];
startClientX: number;
startClientY: number;
startLayerX: number;
startLayerY: number;
startLayers: Array<{ id: string; x: number; y: number }>;
startGenerationFrames?: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
kind: 'generation-frame';
dialogId: string;
dialogIds: string[];
layerIds: string[];
pointerId: number;
startClientX: number;
startClientY: number;
startFrameX: number;
startFrameY: number;
startFrames: Array<{ id: string; x: number; y: number }>;
startLayers: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
kind: 'minimap';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
minimapScale: number;
moved: boolean;
};
export type CanvasPositionStyle = {
left: string | number;
top: string | number;
width: string | number;
height: string | number;
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@genarrative/image-canvas-react",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles.css": "./src/styles.css"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
@@ -0,0 +1,101 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
CanvasChromeButton,
CanvasToolbar,
CanvasToolbarDivider,
CanvasToolbarGroup,
} from './CanvasChrome';
describe('shared canvas chrome', () => {
it('forwards native button props and exposes shared accessible states', () => {
const onClick = vi.fn();
const ref = createRef<HTMLButtonElement>();
render(
<CanvasChromeButton
ref={ref}
label="选择工具"
title="选择"
icon={<span data-testid="tool-icon" />}
className="host-button"
data-host="main-site"
pressed
expanded={false}
onClick={onClick}
/>,
);
const button = screen.getByRole('button', { name: '选择工具' });
expect(button.className).toContain(
'genarrative-image-canvas__chrome-button',
);
expect(button.className).toContain('host-button');
expect(button.getAttribute('type')).toBe('button');
expect(button.getAttribute('title')).toBe('选择');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('data-host')).toBe('main-site');
expect(ref.current).toBe(button);
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('supports a short visible label and native disabled behavior', () => {
const onClick = vi.fn();
render(
<CanvasChromeButton
label="改造素材"
icon={<span />}
disabled
onClick={onClick}
>
改造
</CanvasChromeButton>,
);
const button = screen.getByRole('button', { name: '改造素材' });
expect(button.className).toContain(
'genarrative-image-canvas__chrome-button--with-label',
);
expect(button.getAttribute('title')).toBe('改造素材');
expect(button).toHaveProperty('disabled', true);
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it('provides a named toolbar with groups and decorative dividers', () => {
render(
<CanvasToolbar
label="素材工具栏"
surface="floating"
className="host-toolbar"
>
<CanvasToolbarGroup data-testid="toolbar-group">
<CanvasChromeButton label="下载" icon={<span />} />
</CanvasToolbarGroup>
<CanvasToolbarDivider data-testid="toolbar-divider" />
</CanvasToolbar>,
);
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
expect(toolbar.className).toContain(
'genarrative-image-canvas__toolbar--floating',
);
expect(toolbar.className).toContain('host-toolbar');
expect(toolbar.getAttribute('aria-orientation')).toBe('horizontal');
expect(screen.getByTestId('toolbar-group').className).toContain(
'genarrative-image-canvas__toolbar-group',
);
expect(
screen.getByTestId('toolbar-divider').getAttribute('aria-hidden'),
).toBe('true');
});
});
@@ -0,0 +1,152 @@
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
type ReactNode,
} from 'react';
function joinClassNames(...classNames: Array<string | undefined | false>) {
return classNames.filter(Boolean).join(' ');
}
export type CanvasChromeButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-expanded' | 'aria-label' | 'aria-pressed' | 'children'
> & {
label: string;
icon: ReactNode;
children?: ReactNode;
pressed?: boolean;
expanded?: boolean;
};
/**
* 画布宿主共享的动作按钮,只承接 chrome、可访问性与原生按钮状态。
*/
export const CanvasChromeButton = forwardRef<
HTMLButtonElement,
CanvasChromeButtonProps
>(function CanvasChromeButton(
{
label,
icon,
children,
pressed,
expanded,
title = label,
type = 'button',
className,
...buttonProps
},
ref,
) {
const hasVisibleLabel =
children !== undefined && children !== null && children !== false;
return (
<button
{...buttonProps}
ref={ref}
type={type}
className={joinClassNames(
'genarrative-image-canvas__chrome-button',
hasVisibleLabel &&
'genarrative-image-canvas__chrome-button--with-label',
className,
)}
aria-label={label}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
>
<span className="genarrative-image-canvas__chrome-button-icon">
{icon}
</span>
{hasVisibleLabel ? (
<span className="genarrative-image-canvas__chrome-button-label">
{children}
</span>
) : null}
</button>
);
});
export type CanvasToolbarProps = Omit<
HTMLAttributes<HTMLDivElement>,
'aria-label' | 'children' | 'role'
> & {
label: string;
children: ReactNode;
surface?: 'plain' | 'floating';
};
/** 画布宿主共享的横向工具栏容器。 */
export const CanvasToolbar = forwardRef<HTMLDivElement, CanvasToolbarProps>(
function CanvasToolbar(
{ label, children, surface = 'plain', className, ...toolbarProps },
ref,
) {
return (
<div
{...toolbarProps}
ref={ref}
role="toolbar"
aria-label={label}
aria-orientation="horizontal"
className={joinClassNames(
'genarrative-image-canvas__toolbar',
`genarrative-image-canvas__toolbar--${surface}`,
className,
)}
>
{children}
</div>
);
},
);
export type CanvasToolbarGroupProps = HTMLAttributes<HTMLSpanElement> & {
children: ReactNode;
};
/** 将同类画布动作保持为不可拆散的横向分组。 */
export const CanvasToolbarGroup = forwardRef<
HTMLSpanElement,
CanvasToolbarGroupProps
>(function CanvasToolbarGroup({ children, className, ...groupProps }, ref) {
return (
<span
{...groupProps}
ref={ref}
className={joinClassNames(
'genarrative-image-canvas__toolbar-group',
className,
)}
>
{children}
</span>
);
});
export type CanvasToolbarDividerProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'aria-hidden' | 'children'
>;
/** 纯视觉工具分隔符,不进入无障碍树。 */
export const CanvasToolbarDivider = forwardRef<
HTMLSpanElement,
CanvasToolbarDividerProps
>(function CanvasToolbarDivider({ className, ...dividerProps }, ref) {
return (
<span
{...dividerProps}
ref={ref}
aria-hidden="true"
className={joinClassNames(
'genarrative-image-canvas__toolbar-divider',
className,
)}
/>
);
});
@@ -0,0 +1,41 @@
import { createContext, type ReactNode, useContext } from 'react';
import { createPortal } from 'react-dom';
const CanvasPortalRootContext = createContext<HTMLElement | null>(null);
export function CanvasPortalProvider({
children,
root,
}: {
children: ReactNode;
root: HTMLElement | null;
}) {
return (
<CanvasPortalRootContext.Provider value={root}>
{children}
</CanvasPortalRootContext.Provider>
);
}
export function CanvasPortal({
children,
className = '',
container,
}: {
children: ReactNode;
className?: string;
container?: HTMLElement | null;
}) {
const contextualRoot = useContext(CanvasPortalRootContext);
const portalRoot =
container ??
contextualRoot ??
(typeof document === 'undefined' ? null : document.body);
if (!portalRoot) return <>{children}</>;
return createPortal(
<div className={`genarrative-image-canvas__portal ${className}`.trim()}>
{children}
</div>,
portalRoot,
);
}
@@ -0,0 +1,50 @@
import type { CanvasTool } from '@genarrative/image-canvas-core';
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
export type CanvasViewportProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
backgroundColor?: string;
isInteractionPaused?: boolean;
isPanning?: boolean;
tool?: CanvasTool;
};
export const CanvasViewport = forwardRef<HTMLDivElement, CanvasViewportProps>(
function CanvasViewport(
{
children,
backgroundColor,
className = '',
isInteractionPaused = false,
isPanning = false,
style,
tool = 'select',
...props
},
ref,
) {
return (
<div
{...props}
ref={ref}
className={[
'genarrative-image-canvas',
'genarrative-image-canvas__viewport',
isPanning ? 'genarrative-image-canvas__viewport--panning' : '',
`genarrative-image-canvas__viewport--tool-${tool}`,
className,
]
.filter(Boolean)
.join(' ')}
style={{
...style,
backgroundColor,
pointerEvents: isInteractionPaused ? 'none' : style?.pointerEvents,
}}
aria-disabled={isInteractionPaused || undefined}
>
{children}
</div>
);
},
);
@@ -0,0 +1,33 @@
import {
CANVAS_WORLD_SIZE,
type CanvasViewport,
} from '@genarrative/image-canvas-core';
import type { HTMLAttributes, ReactNode } from 'react';
export function CanvasWorld({
children,
className = '',
viewport,
worldSize = CANVAS_WORLD_SIZE,
style,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
viewport: CanvasViewport;
worldSize?: number;
}) {
return (
<div
{...props}
className={`genarrative-image-canvas__world ${className}`.trim()}
style={{
...style,
width: worldSize,
height: worldSize,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,52 @@
import type { CanvasLayer } from '@genarrative/image-canvas-core';
import type { HTMLAttributes, KeyboardEventHandler, ReactNode } from 'react';
export function LayerRenderer({
as = 'button',
children,
className = '',
generating = false,
hovered = false,
layer,
selected = false,
style,
...props
}: Omit<HTMLAttributes<HTMLElement>, 'style'> & {
as?: 'button' | 'div';
children: ReactNode;
generating?: boolean;
hovered?: boolean;
layer: CanvasLayer;
selected?: boolean;
style?: HTMLAttributes<HTMLElement>['style'];
onKeyDown?: KeyboardEventHandler<HTMLElement>;
}) {
const sharedProps = {
...props,
className: [
'genarrative-image-canvas__layer',
selected ? 'genarrative-image-canvas__layer--selected' : '',
hovered ? 'genarrative-image-canvas__layer--hovered' : '',
generating ? 'genarrative-image-canvas__layer--generating' : '',
layer.locked ? 'genarrative-image-canvas__layer--locked' : '',
className,
]
.filter(Boolean)
.join(' '),
style: {
...style,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : style?.display,
},
children,
};
return as === 'div' ? (
<div {...sharedProps} role={props.role ?? 'button'} />
) : (
<button {...sharedProps} type="button" />
);
}
@@ -0,0 +1,48 @@
import type { StageMinimapModel } from '@genarrative/image-canvas-core';
import type { PointerEventHandler, Ref } from 'react';
export function Minimap({
className = '',
layerClassName = '',
minimapRef,
model,
onPointerDown,
stageClassName = '',
viewportClassName = '',
}: {
className?: string;
layerClassName?: string;
minimapRef?: Ref<HTMLButtonElement>;
model: StageMinimapModel;
onPointerDown: PointerEventHandler<HTMLButtonElement>;
stageClassName?: string;
viewportClassName?: string;
}) {
return (
<button
ref={minimapRef}
type="button"
className={`genarrative-image-canvas__minimap ${className}`.trim()}
aria-label="画布小地图"
title="拖拽移动视图"
onPointerDown={onPointerDown}
>
<span
className={`genarrative-image-canvas__minimap-stage ${stageClassName}`.trim()}
>
{model.layers.map((layer) => (
<span
key={layer.id}
className={`genarrative-image-canvas__minimap-layer ${layerClassName}`.trim()}
title={layer.title}
style={layer.rect}
/>
))}
<span
className={`genarrative-image-canvas__minimap-viewport ${viewportClassName}`.trim()}
style={model.viewport}
/>
</span>
</button>
);
}
@@ -0,0 +1,32 @@
import type {
CanvasMarqueeState,
CanvasViewport,
} from '@genarrative/image-canvas-core';
export function SelectionOverlay({
className = '',
marquee,
viewport,
}: {
className?: string;
marquee: CanvasMarqueeState | null;
viewport: CanvasViewport;
}) {
if (!marquee) return null;
return (
<div
className={`genarrative-image-canvas__selection-overlay ${className}`.trim()}
aria-hidden="true"
style={{
left:
(Math.min(marquee.startX, marquee.currentX) - viewport.x) /
viewport.scale,
top:
(Math.min(marquee.startY, marquee.currentY) - viewport.y) /
viewport.scale,
width: Math.abs(marquee.currentX - marquee.startX) / viewport.scale,
height: Math.abs(marquee.currentY - marquee.startY) / viewport.scale,
}}
/>
);
}
@@ -0,0 +1,36 @@
import {
canvasDisplayScaleToViewportScale,
type CanvasViewport,
formatCanvasDisplayScalePercent,
} from '@genarrative/image-canvas-core';
import type { ReactNode } from 'react';
export type CanvasZoomActions = {
displayPercent: string;
fit: () => void;
zoomIn: () => void;
zoomOut: () => void;
zoomToDisplayScale: (displayScale: number) => void;
};
export function ZoomControls({
children,
onFit,
onScaleFromCenter,
viewport,
}: {
children: (actions: CanvasZoomActions) => ReactNode;
onFit: () => void;
onScaleFromCenter: (scale: number) => void;
viewport: CanvasViewport;
}) {
const actions: CanvasZoomActions = {
displayPercent: formatCanvasDisplayScalePercent(viewport.scale),
fit: onFit,
zoomIn: () => onScaleFromCenter(viewport.scale * 1.16),
zoomOut: () => onScaleFromCenter(viewport.scale * 0.86),
zoomToDisplayScale: (displayScale) =>
onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)),
};
return <>{children(actions)}</>;
}

Some files were not shown because too many files have changed in this diff Show More