抽取网站与客户端共用的无限画布核心源码
Project CI / Repository checks (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Successful in 3m35s
Project CI / Native shell tests (pull_request) Successful in 11m32s

迁移画布交互模型、历史与舞台控制到共享 core
迁移画布视口、世界、图层、框选、小地图和 portal 到共享 React
让主站通过兼容导出和共享组件消费唯一实现
补齐 Web Host Port 边界与 Tauri 共享源码构建配置
增加双实例、重复挂载和核心交互回归测试
同步无限画布阶段二实施边界与项目记忆
This commit is contained in:
2026-08-05 13:43:14 +08:00
parent 3522285894
commit a5a8a6b085
63 changed files with 6218 additions and 4831 deletions
@@ -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';
+2 -2
View File
@@ -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 =
+10 -1
View File
@@ -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"]
}
+33 -10
View File
@@ -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 门禁以本次任务最终验证记录为准。
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@genarrative/image-canvas-core",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
@@ -0,0 +1,59 @@
import type { CanvasLayer } from './types';
export type CanvasExportLayer = {
id: string;
title: string;
mediaType: 'image';
mediaRef: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
flipX: boolean;
flipY: boolean;
};
export type CanvasExportModel = {
bounds: { x: number; y: number; width: number; height: number };
backgroundColor: string;
layers: CanvasExportLayer[];
};
export function createCanvasExportModel({
layers,
backgroundColor,
}: {
layers: CanvasLayer[];
backgroundColor: string;
}): CanvasExportModel | null {
const visibleImages = layers
.filter(
(layer) => !layer.hidden && (layer.mediaType ?? 'image') === 'image',
)
.sort((first, second) => first.zIndex - second.zIndex);
if (visibleImages.length === 0) return null;
const minX = Math.min(...visibleImages.map((layer) => layer.x));
const minY = Math.min(...visibleImages.map((layer) => layer.y));
const maxX = Math.max(...visibleImages.map((layer) => layer.x + layer.width));
const maxY = Math.max(
...visibleImages.map((layer) => layer.y + layer.height),
);
return {
bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
backgroundColor,
layers: visibleImages.map((layer) => ({
id: layer.id,
title: layer.title,
mediaType: 'image',
mediaRef: layer.objectKey ?? layer.assetObjectId ?? layer.src,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
flipX: Boolean(layer.flipX),
flipY: Boolean(layer.flipY),
})),
};
}
+282
View File
@@ -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),
);
}
+12
View File
@@ -0,0 +1,12 @@
export * from './exportModel';
export * from './history';
export * from './interaction';
export * from './layerCommands';
export * from './layerTransform';
export * from './model';
export * from './overlays';
export * from './ports';
export * from './selection';
export * from './stageController';
export * from './stageInteractions';
export * from './types';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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),
};
});
}
+285
View File
@@ -0,0 +1,285 @@
import type { CanvasLayer, CanvasSnapItem, SnapCandidate } from './types';
export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.025;
export const MAX_SCALE = 3.2;
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const FIT_VIEW_PADDING = 10;
export const MINIMAP_SIZE = { width: 132, height: 84 };
export const MINIMAP_PADDING = 8;
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
export const MAX_HISTORY_STEPS = 60;
export const SNAP_THRESHOLD_SCREEN_PX = 18;
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
export function formatCanvasDisplayScalePercent(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
}
export function canvasDisplayScaleToViewportScale(displayScale: number) {
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
}
export function viewportScaleToCanvasDisplayScale(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
}
export function getLayerBounds(targetLayers: CanvasLayer[]) {
if (targetLayers.length === 0) {
return null;
}
return targetLayers.reduce(
(current, layer) => ({
minX: Math.min(current.minX, layer.x),
minY: Math.min(current.minY, layer.y),
maxX: Math.max(current.maxX, layer.x + layer.width),
maxY: Math.max(current.maxY, layer.y + layer.height),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
);
}
export function resolveSnappedItemPosition(
movingItem: CanvasSnapItem,
proposedX: number,
proposedY: number,
items: CanvasSnapItem[],
scale: number,
) {
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
const visibleItems = items.filter(
(item) => item.id !== movingItem.id && !item.hidden,
);
const verticalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...visibleItems.flatMap((item) => [
item.x,
item.x + item.width / 2,
item.x + item.width,
]),
];
const horizontalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...visibleItems.flatMap((item) => [
item.y,
item.y + item.height / 2,
item.y + item.height,
]),
];
const xAlignmentSnap = findNearestSnap(
proposedX,
[0, movingItem.width / 2, movingItem.width],
verticalTargets,
threshold,
);
const yAlignmentSnap = findNearestSnap(
proposedY,
[0, movingItem.height / 2, movingItem.height],
horizontalTargets,
threshold,
);
const xDistributionSnap = findNearestEqualSpacingSnap({
axis: 'x',
proposedStart: proposedX,
proposedCrossStart: proposedY,
movingSize: movingItem.width,
movingCrossSize: movingItem.height,
items: visibleItems,
threshold,
});
const yDistributionSnap = findNearestEqualSpacingSnap({
axis: 'y',
proposedStart: proposedY,
proposedCrossStart: proposedX,
movingSize: movingItem.height,
movingCrossSize: movingItem.width,
items: visibleItems,
threshold,
});
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
return {
x: xSnap ? xSnap.position : proposedX,
y: ySnap ? ySnap.position : proposedY,
guide:
xSnap || ySnap
? { vertical: xSnap?.guide, horizontal: ySnap?.guide }
: null,
};
}
function chooseNearestSnap(
first: SnapCandidate | null,
second: SnapCandidate | null,
) {
if (!first) return second;
if (!second) return first;
return second.distance < first.distance ? second : first;
}
function findNearestEqualSpacingSnap({
axis,
proposedStart,
proposedCrossStart,
movingSize,
movingCrossSize,
items,
threshold,
}: {
axis: 'x' | 'y';
proposedStart: number;
proposedCrossStart: number;
movingSize: number;
movingCrossSize: number;
items: CanvasSnapItem[];
threshold: number;
}): SnapCandidate | null {
const orderedItems = items
.filter((item) =>
snapItemsOverlapOnCrossAxis(
proposedCrossStart,
movingCrossSize,
item,
axis,
),
)
.sort(
(firstItem, secondItem) =>
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
);
let nearest: SnapCandidate | null = null;
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
const firstItem = orderedItems[firstIndex];
if (!firstItem) continue;
for (
let secondIndex = firstIndex + 1;
secondIndex < orderedItems.length &&
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
secondIndex += 1
) {
const secondItem = orderedItems[secondIndex];
if (!secondItem) continue;
const firstStart = getSnapItemStart(firstItem, axis);
const firstEnd = getSnapItemEnd(firstItem, axis);
const secondStart = getSnapItemStart(secondItem, axis);
const secondEnd = getSnapItemEnd(secondItem, axis);
const pairGap = secondStart - firstEnd;
if (pairGap < 0) continue;
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstStart - pairGap - movingSize,
proposedStart,
movingSize,
threshold,
}),
);
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: secondEnd + pairGap,
proposedStart,
movingSize,
threshold,
}),
);
const betweenGap = (pairGap - movingSize) / 2;
if (betweenGap >= 0) {
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstEnd + betweenGap,
proposedStart,
movingSize,
threshold,
}),
);
}
}
}
return nearest;
}
function createDistributionSnapCandidate({
position,
proposedStart,
movingSize,
threshold,
}: {
position: number;
proposedStart: number;
movingSize: number;
threshold: number;
}): SnapCandidate | null {
const distance = Math.abs(position - proposedStart);
return distance > threshold
? null
: { position, guide: position + movingSize / 2, distance };
}
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.x : item.y;
}
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.width : item.height;
}
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
}
function snapItemsOverlapOnCrossAxis(
proposedCrossStart: number,
movingCrossSize: number,
item: CanvasSnapItem,
axis: 'x' | 'y',
) {
const itemCrossStart = axis === 'x' ? item.y : item.x;
const itemCrossSize = axis === 'x' ? item.height : item.width;
return (
proposedCrossStart <=
itemCrossStart + itemCrossSize - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
proposedCrossStart + movingCrossSize >=
itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
);
}
export function findNearestSnap(
origin: number,
offsets: number[],
targets: number[],
threshold: number,
): SnapCandidate | null {
let nearest: SnapCandidate | null = null;
for (const offset of offsets) {
for (const target of targets) {
const distance = Math.abs(target - (origin + offset));
if (distance <= threshold && (!nearest || distance < nearest.distance)) {
nearest = { position: target - offset, guide: target, distance };
}
}
}
return nearest;
}
+215
View File
@@ -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'
);
}
+170
View File
@@ -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;
}
+348
View File
@@ -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;
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@genarrative/image-canvas-react",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles.css": "./src/styles.css"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
@@ -0,0 +1,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