合并master并保留Spine序列帧修复
合并master当前工程、后端、前端和文档更新 按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突 保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user