抽取网站与客户端共用的无限画布核心源码
迁移画布交互模型、历史与舞台控制到共享 core 迁移画布视口、世界、图层、框选、小地图和 portal 到共享 React 让主站通过兼容导出和共享组件消费唯一实现 补齐 Web Host Port 边界与 Tauri 共享源码构建配置 增加双实例、重复挂载和核心交互回归测试 同步无限画布阶段二实施边界与项目记忆
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageCanvasAssetPort,
|
||||
ImageCanvasCompletionPort,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasProjectPort,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
export {
|
||||
CanvasViewport as SharedCanvasViewport,
|
||||
CanvasWorld as SharedCanvasWorld,
|
||||
LayerRenderer as SharedLayerRenderer,
|
||||
Minimap as SharedMinimap,
|
||||
SelectionOverlay as SharedSelectionOverlay,
|
||||
ZoomControls as SharedZoomControls,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@genarrative/image-canvas-react/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
import React from 'react';
|
||||
@@ -12,8 +13,7 @@ import {
|
||||
} from './App';
|
||||
|
||||
const initialSearchParams = new URLSearchParams(window.location.search);
|
||||
const gameChatReleaseMode =
|
||||
import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true';
|
||||
const gameChatReleaseMode = import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true';
|
||||
const supervisorChatMode =
|
||||
import.meta.env.DEV && initialSearchParams.has('supervisor-chat');
|
||||
const gameChatMode =
|
||||
|
||||
@@ -13,7 +13,16 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": "../..",
|
||||
"paths": {
|
||||
"@genarrative/image-canvas-core": [
|
||||
"packages/image-canvas-core/src/index.ts"
|
||||
],
|
||||
"@genarrative/image-canvas-react": [
|
||||
"packages/image-canvas-react/src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -47,19 +47,42 @@ export default defineConfig({
|
||||
{
|
||||
name: 'genarrative-ai-game-creator-dev-marker',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__agc_dev_server.json', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
app: 'ai-game-creator-shell',
|
||||
apiTarget,
|
||||
}),
|
||||
);
|
||||
});
|
||||
server.middlewares.use(
|
||||
'/__agc_dev_server.json',
|
||||
(_request, response) => {
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
app: 'ai-game-creator-shell',
|
||||
apiTarget,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@genarrative/image-canvas-core': resolve(
|
||||
repoRoot,
|
||||
'packages/image-canvas-core/src/index.ts',
|
||||
),
|
||||
'@genarrative/image-canvas-react/styles.css': resolve(
|
||||
repoRoot,
|
||||
'packages/image-canvas-react/src/styles.css',
|
||||
),
|
||||
'@genarrative/image-canvas-react': resolve(
|
||||
repoRoot,
|
||||
'packages/image-canvas-react/src/index.ts',
|
||||
),
|
||||
},
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 3080,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-05 无限画布阶段二以共享包成为网站与 Tauri 唯一核心源码
|
||||
|
||||
- 决策:把现役网站画布的纯交互模型、history/viewport/stage hooks 和通用 React 容器迁到 `packages/image-canvas-core`、`packages/image-canvas-react`;原 `src/components/image-editor` 同名模型/hook 只保留 re-export。网站 Stage/World/Dock/Portal 已直接使用共享 React,Tauri 壳只建立共享源码编译入口和作用域 CSS,不在本阶段接素材创作业务闭环或修改资源总览。
|
||||
- 依赖边界:core 只含纯 TypeScript;React 包只依赖 core、React/ReactDOM 和注入端口。project、asset、generation、completion 四组 ports 不知道 auth、wallet、payment、routing、`editorProjectClient` 或 Tauri `invoke/listen`。网站账号、计费、云端项目、素材库、上传、生成、媒体换签与业务面板继续留在网站 adapter/renderer 边界。
|
||||
- 宿主构建:根站与 AI 游戏创作壳分别 alias 同一 `packages/` 源码,Vite 去重 React/ReactDOM;Tauri dev server 显式允许 repo root。共享 CSS 只使用 `.genarrative-image-canvas` 作用域,Tauri 不导入网站 `src/index.css`。
|
||||
- 实例隔离:小地图优先在当前 viewport 内解析,多实例时禁止全局取第一个;wheel、ResizeObserver、animation frame 与 portal 均按实例挂载清理。重复 mount/unmount 和双画布隔离由共享 React 测试锁定。
|
||||
- 当前未解耦:网站 `ImageCanvasEditorView` 仍负责完整业务编排,媒体换签、视频/音频/序列帧 renderer、Agent 与生成表单未下沉;旧 `ImageCanvasEditorTypes` 和 `ImageCanvasEditorModel` 仍含网站扩展类型与序列化/素材规则。后续接 Tauri 时应通过 ports 和 renderer 插槽继续缩小这些边界,禁止把该目录整体搬入客户端。
|
||||
- 验证:共享 core/React 7 项测试与现役 ImageCanvas 74 个文件、892 项测试通过;网站和 AI 游戏创作壳 typecheck 通过。构建、编码和 diff 门禁以本次任务最终记录为准。
|
||||
- 关联:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。
|
||||
|
||||
## 2026-08-05 素材创作无限画布共享源码并以本地事务形成图片闭环
|
||||
|
||||
- 背景:资源管理阶段七已经完成 manifest 实时投影、dependency/type 布局、依赖图和中央只读聚焦,但“新增资源/精修资源”仍缺少草稿、生成、正式回写、血缘、布局和焦点竞态的完整合同。网站已有成熟图片画布,若直接复制进 Tauri 会形成两份长期分叉的画布内核和通用 UI。
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 共享画布不能用全局 DOM 查询或宿主整包 CSS 作为隐式依赖
|
||||
|
||||
- 现象:页面挂两个画布时,第二个画布点击小地图会移动第一个画布;反复挂载后 wheel 触发多次。Tauri 单独引入网站 `index.css` 时还会带入账号、项目页和历史业务样式,或因仓库外源码解析到第二份 React 而出现 Hook 错误。
|
||||
- 原因:`document.querySelector`、body 级 portal、未清理的 listener/observer/animation frame 和不受作用域约束的 CSS 都把组件实例与网站宿主当成全局单例;Tauri Vite 默认根目录又不等于仓库根,React 解析路径可能分叉。
|
||||
- 处理:共享小地图先从当前 viewport 查询,只有文档中唯一候选时才兼容旧单实例形态;wheel、ResizeObserver 和 animation frame 在 effect cleanup 中逐项释放。portal 支持实例 root,默认 body 只作兼容。共享 CSS 全部限定在 `.genarrative-image-canvas`,两宿主 alias 同一 `packages/` 源码并 dedupe React/ReactDOM,Tauri `fs.allow` 覆盖 repo root,禁止导入主站完整 `index.css`。
|
||||
- 验证:共享 React 测试重复 mount/unmount 后 wheel add/remove 数量相等、ResizeObserver 精确 disconnect,并挂两个含各自小地图的 viewport,确认只更新目标实例;网站与 Tauri 壳分别 typecheck/build。
|
||||
- 关联:`packages/image-canvas-react/src/useImageCanvasViewportControls.ts`、`packages/image-canvas-react/src/CanvasPortal.tsx`、根目录与 `apps/ai-game-creator-shell` 的 Vite 配置。
|
||||
|
||||
## 正式素材提交不能把多文件写入或 Tauri 事件误当成一次原子动作
|
||||
|
||||
- 现象:图片已经落到 `assets/` 但 manifest 没有资产,或 manifest 已追加而 project revision/草稿仍是旧值;进程在 emit 前后退出后,用户重试又得到第二份图片、第二个 asset 或重复选中。
|
||||
|
||||
@@ -918,3 +918,13 @@ game-project/
|
||||
- refine 不覆盖源文件或复用源 asset ID。源缺少外部 resourceId 时补齐 `local-asset:<manifestAssetId>`,新 `canvas-<commitId>` 资产用 `referenceResourceIds` 登记源和其它直接引用,避免混淆 manifest asset ID 与 External Editor resource ID。
|
||||
- 提交返回完整最新 manifest;当前项目仍匹配时立即更新项目上下文、资源投影、依赖图和两种布局,布局 ready 后才按焦点守卫决定选中。用户已切项目、切状态、开始新 session、选择其它资源或改变搜索/筛选时,迟到结果不得抢焦点;新资源被隐藏时保留条件并提供显式清除/定位动作。
|
||||
- 图片首版包含平移、缩放、图层选择/移动/缩放、撤销重做、导入、基础编辑、生成、导出和本地回写。高级抠图、图集、角色动画、视频与音频编辑后续分期;本阶段不修改 SpacetimeDB schema。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段二共享源码抽取
|
||||
|
||||
- 阶段二只完成网站与 Tauri 可共同解析的唯一画布源码,不进入 Tauri 素材创作业务闭环,也不修改 `project-development` 资源总览页面。`apps/ai-game-creator-shell` 只增加共享包解析、React 去重、作用域 CSS 引入和 `features/asset-canvas/sharedImageCanvasFoundation.ts` 编译入口;草稿 sidecar、Tauri adapter、中央状态接线、生成、正式提交和即时投影仍按阶段一合同留待后续阶段。
|
||||
- `packages/image-canvas-core/src/` 是纯 TypeScript 边界:`types/model/interaction/selection/layerCommands/layerTransform/history/overlays/stageController/stageInteractions/exportModel/ports` 分别承接通用类型与常量、坐标/平移/缩放/适配内容/小地图、单选/多选/框选、图层命令和缩放变换、历史恢复、overlay 定位、stage controller、基础导出模型,以及 project/asset/generation/completion 四组 Host Port。该包不依赖 React、DOM、网站客户端或 Tauri API。
|
||||
- `packages/image-canvas-react/src/` 承接 `CanvasViewport`、`CanvasWorld`、`LayerRenderer`、`SelectionOverlay`、`ZoomControls`、`Minimap`、`CanvasPortal` 和现役 history/viewport/stage hooks。网站 `ImageCanvasStageView`、`ImageCanvasWorldView`、`ImageCanvasPanelDockView` 与 `ImageCanvasEditorPortal` 已直接消费这些组件;旧模型和 hook 文件只保留无业务逻辑 re-export,网站与后续客户端不会再维护第二份核心实现。
|
||||
- 网站继续保留 `ImageCanvasEditorView` 的账号门禁、钱包/计费展示、路由、`editorProjectClient`、云端项目持久化、素材库、上传、生成、媒体换签与业务面板。`src/components/image-editor/host/webImageCanvasHostAdapter.ts` 只负责把这些网站事实组合到共享四组 ports;共享包不读取账号 store、钱包 store、HTTP client 或网站别名。现役视频、音频、序列帧播放器和 Agent 面板仍属于网站 renderer 插槽边界,本阶段没有误下沉到图片首版 core。
|
||||
- 共享样式固定为 `packages/image-canvas-react/src/styles.css`,全部以 `.genarrative-image-canvas` 作用域开头;网站可以继续用 `src/index.css` 提供站点皮肤,Tauri 只导入共享样式和自身 `styles.css`。两套 Vite 都把共享源码显式 alias 到仓库 `packages/`,Tauri dev server 保留 `fs.allow=[repoRoot]`,并通过 `resolve.dedupe=['react','react-dom']` 保证单 React 实例。
|
||||
- portal 默认进入当前 Provider 指定的 root,未指定时才回退 `document.body`;事件监听、`ResizeObserver`、animation frame 和小地图查询均按组件实例清理或限定在当前 viewport。文档中只有一个小地图的旧调用形态允许唯一元素回退,页面存在多个小地图时不做全局猜测。
|
||||
- 阶段二定向证据为共享 core/React 7 项与现役 `src/components/image-editor` 74 个文件、892 项测试通过;主站与 AI 游戏创作壳 typecheck 通过。构建、编码和 diff 门禁以本次任务最终验证记录为准。
|
||||
|
||||
@@ -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,282 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
GenerateDialogState,
|
||||
} from './types';
|
||||
|
||||
const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
CanvasHistoryAction['type'],
|
||||
string
|
||||
> = {
|
||||
'move-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': '移除背景',
|
||||
'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',
|
||||
'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 &&
|
||||
currentFrame.frameIndex === targetFrame.frameIndex &&
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
function cloneDialogPlaceholder(
|
||||
dialog: GenerateDialogState,
|
||||
): GenerateDialogState['placeholder'] {
|
||||
return dialog.placeholder ? { ...dialog.placeholder } : undefined;
|
||||
}
|
||||
|
||||
function mergeLayerForHistoryRestore(
|
||||
target: CanvasLayer,
|
||||
current: CanvasLayer | undefined,
|
||||
restoreAssetKind: 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,
|
||||
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,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
assetKindLayerIds?: 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,
|
||||
),
|
||||
),
|
||||
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,255 @@
|
||||
import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasLayer,
|
||||
} from './types';
|
||||
import { getCanvasGenerationSelectionId } from './selection';
|
||||
|
||||
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
|
||||
? {
|
||||
fields: layer.generationInputs.fields.map((field) => ({ ...field })),
|
||||
references: layer.generationInputs.references.map((reference) => ({
|
||||
...reference,
|
||||
})),
|
||||
}
|
||||
: 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,215 @@
|
||||
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 === '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,170 @@
|
||||
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: 'host-resource'; resourceId: string };
|
||||
|
||||
export type ImageCanvasDraft = {
|
||||
draftId: string;
|
||||
projectId: string;
|
||||
revision: number;
|
||||
hostRevision: string;
|
||||
canvas: unknown;
|
||||
generations: ImageCanvasGenerationRecord[];
|
||||
};
|
||||
|
||||
export type ImageCanvasGenerationRecord = {
|
||||
generationId: string;
|
||||
idempotencyKey: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
resourceIds: string[];
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
expectedDraftRevision: number;
|
||||
generationId: string;
|
||||
idempotencyKey: string;
|
||||
prompt: string;
|
||||
referenceResourceIds: string[];
|
||||
}): Promise<
|
||||
ImageCanvasHostResult<{
|
||||
generation: ImageCanvasGenerationRecord;
|
||||
images: ImageCanvasHostImage[];
|
||||
}>
|
||||
>;
|
||||
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;
|
||||
draftRevision: number;
|
||||
hostRevision: 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,159 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createCanvasExportModel,
|
||||
createMinimapModel,
|
||||
fitViewportToLayers,
|
||||
getWorldPointFromClient,
|
||||
moveLayersFromDrag,
|
||||
panViewportFromWheel,
|
||||
resizeCanvasLayerBounds,
|
||||
resolvePointerSelection,
|
||||
scaleViewportFromScreenPoint,
|
||||
selectLayersInsideMarquee,
|
||||
transformCanvasLayers,
|
||||
type CanvasLayer,
|
||||
} 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('supports additive selection, marquee and multi-layer movement', () => {
|
||||
expect(
|
||||
resolvePointerSelection({
|
||||
targetId: 'b',
|
||||
selectedIds: ['a'],
|
||||
isMultiSelectGesture: true,
|
||||
}),
|
||||
).toEqual(['a', '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 type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './types';
|
||||
import {
|
||||
getCanvasLayersByIds,
|
||||
resolveContextTargetLayerIds,
|
||||
} from './layerCommands';
|
||||
import { resolveSelectedToolbarStyle } from './overlays';
|
||||
import {
|
||||
getSelectedGenerationDialogIds,
|
||||
getSelectedLayerIds,
|
||||
} from './selection';
|
||||
|
||||
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,439 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasViewport,
|
||||
DragState,
|
||||
GenerateDialogState,
|
||||
} from './types';
|
||||
import type { CanvasPoint } from './interaction';
|
||||
import {
|
||||
firstSelectedLayerId,
|
||||
getCanvasGenerationSelectionId,
|
||||
getSelectedGenerationDialogIds,
|
||||
getSelectedLayerIds,
|
||||
resolvePointerSelection,
|
||||
} from './selection';
|
||||
|
||||
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',
|
||||
'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,348 @@
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind =
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'character-animation'
|
||||
| 'icon'
|
||||
| 'icon-spritesheet'
|
||||
| 'icon-spec'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
| 'sound-effect'
|
||||
| 'background-music';
|
||||
|
||||
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
|
||||
|
||||
export type CanvasGenerationInputField = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CanvasGenerationInputReference = {
|
||||
title: string;
|
||||
label: string;
|
||||
refType: 'project-resource' | 'asset';
|
||||
refId: string;
|
||||
};
|
||||
|
||||
export type CanvasGenerationInputs = {
|
||||
fields: CanvasGenerationInputField[];
|
||||
references: CanvasGenerationInputReference[];
|
||||
};
|
||||
|
||||
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;
|
||||
durationSeconds?: number;
|
||||
imageSequenceFrames?: CanvasSequenceFrame[];
|
||||
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'
|
||||
| 'music'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design';
|
||||
|
||||
export type CanvasGenerationDialogMode =
|
||||
| 'generate'
|
||||
| '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' | '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'
|
||||
| 'move-generation-result'
|
||||
| 'delete-image'
|
||||
| 'delete-generation-result'
|
||||
| 'cut-image'
|
||||
| 'paste-image'
|
||||
| 'duplicate-image'
|
||||
| 'add-image'
|
||||
| 'upload-image'
|
||||
| 'generate-image'
|
||||
| 'expand-image'
|
||||
| 'remove-background'
|
||||
| '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,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,51 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
import type { CanvasTool } from '@genarrative/image-canvas-core';
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user