diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/sharedImageCanvasFoundation.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/sharedImageCanvasFoundation.ts new file mode 100644 index 000000000..75977792b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/sharedImageCanvasFoundation.ts @@ -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'; diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index 6044aab30..b72f486e2 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -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 = diff --git a/apps/ai-game-creator-shell/tsconfig.json b/apps/ai-game-creator-shell/tsconfig.json index 8fb985872..b0cd676a5 100644 --- a/apps/ai-game-creator-shell/tsconfig.json +++ b/apps/ai-game-creator-shell/tsconfig.json @@ -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"] } diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index ea761219d..1923480ad 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/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, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bfd16db78..752724074 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e58df155b..fe0ce09c6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 或重复选中。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index bf85145b0..dc392be5e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -918,3 +918,13 @@ game-project/ - refine 不覆盖源文件或复用源 asset ID。源缺少外部 resourceId 时补齐 `local-asset:`,新 `canvas-` 资产用 `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 门禁以本次任务最终验证记录为准。 diff --git a/packages/image-canvas-core/package.json b/packages/image-canvas-core/package.json new file mode 100644 index 000000000..99fc5fa9a --- /dev/null +++ b/packages/image-canvas-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@genarrative/image-canvas-core", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/image-canvas-core/src/exportModel.ts b/packages/image-canvas-core/src/exportModel.ts new file mode 100644 index 000000000..e9d4dee4e --- /dev/null +++ b/packages/image-canvas-core/src/exportModel.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), + })), + }; +} diff --git a/packages/image-canvas-core/src/history.ts b/packages/image-canvas-core/src/history.ts new file mode 100644 index 000000000..fa86e270c --- /dev/null +++ b/packages/image-canvas-core/src/history.ts @@ -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; +}): CanvasHistorySnapshot { + const currentLayerById = new Map( + current.layers.map((layer) => [layer.id, layer] as const), + ); + const currentDialogById = new Map(); + for (const dialog of current.inactiveGenerateDialogs) { + currentDialogById.set(dialog.id, dialog); + } + if (current.generateDialog?.id) { + currentDialogById.set(current.generateDialog.id, current.generateDialog); + } + + const mergeTargetDialog = (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 => + 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), + ); +} diff --git a/packages/image-canvas-core/src/index.ts b/packages/image-canvas-core/src/index.ts new file mode 100644 index 000000000..c9877a985 --- /dev/null +++ b/packages/image-canvas-core/src/index.ts @@ -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'; diff --git a/packages/image-canvas-core/src/interaction.ts b/packages/image-canvas-core/src/interaction.ts new file mode 100644 index 000000000..67b8afbe9 --- /dev/null +++ b/packages/image-canvas-core/src/interaction.ts @@ -0,0 +1,726 @@ +import { + clamp, + DEFAULT_CANVAS_SIZE, + FIT_VIEW_PADDING, + getLayerBounds, + MAX_SCALE, + MIN_SCALE, + MINIMAP_DRAG_SENSITIVITY, + MINIMAP_PADDING, + MINIMAP_SIZE, + resolveSnappedItemPosition, +} from './model'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasMarqueeState, + CanvasPositionStyle, + CanvasViewport, + DragState, +} from './types'; +import { getCanvasGenerationSelectionId } from './selection'; + +export type CanvasSize = { + width: number; + height: number; +}; + +export type CanvasPoint = { + x: number; + y: number; +}; + +export type CanvasRect = { + left: number; + top: number; + right: number; + bottom: number; +}; + +export type CanvasLayerMoveResult = { + layers: CanvasLayer[]; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; +}; + +export type CanvasGenerationFrameMoveResult = { + generationDialogs: CanvasGenerationDialogState[]; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; +}; + +export type StageMinimapModel = { + bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }; + scale: number; + layers: Array<{ + id: string; + title: string; + rect: CanvasPositionStyle; + }>; + viewport: CanvasPositionStyle; +}; + +export function getCanvasPointFromClient({ + clientX, + clientY, + rect, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; +}): CanvasPoint | null { + if (!rect) { + return null; + } + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; +} + +export function getCanvasDropPoint({ + clientX, + clientY, + rect, + canvasSize, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; + canvasSize: CanvasSize; +}) { + return ( + getCanvasPointFromClient({ clientX, clientY, rect }) ?? { + x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, + y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, + } + ); +} + +export function getWorldPointFromClient({ + clientX, + clientY, + rect, + viewport, +}: { + clientX: number; + clientY: number; + rect: CanvasRect | null; + viewport: CanvasViewport; +}) { + const screenX = clientX - (rect?.left ?? 0); + const screenY = clientY - (rect?.top ?? 0); + return { + x: (screenX - viewport.x) / viewport.scale, + y: (screenY - viewport.y) / viewport.scale, + }; +} + +export function fitViewportToLayers({ + layers, + canvasSize, +}: { + layers: CanvasLayer[]; + canvasSize: CanvasSize; +}): CanvasViewport | null { + const bounds = getLayerBounds(layers); + if (!bounds) { + return null; + } + return fitViewportToBounds({ + bounds: { + x: bounds.minX, + y: bounds.minY, + width: bounds.maxX - bounds.minX, + height: bounds.maxY - bounds.minY, + }, + canvasSize, + }); +} + +export function fitViewportToBounds({ + bounds, + canvasSize, + insets = {}, + padding = FIT_VIEW_PADDING, +}: { + bounds: { x: number; y: number; width: number; height: number }; + canvasSize: CanvasSize; + insets?: { top?: number; right?: number; bottom?: number; left?: number }; + padding?: number; +}): CanvasViewport { + const boundsWidth = Math.max(1, bounds.width); + const boundsHeight = Math.max(1, bounds.height); + const leftInset = Math.max(0, insets.left ?? 0) + padding; + const rightInset = Math.max(0, insets.right ?? 0) + padding; + const topInset = Math.max(0, insets.top ?? 0) + padding; + const bottomInset = Math.max(0, insets.bottom ?? 0) + padding; + const availableWidth = Math.max(1, canvasSize.width - leftInset - rightInset); + const availableHeight = Math.max( + 1, + canvasSize.height - topInset - bottomInset, + ); + const scale = clamp( + Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight), + MIN_SCALE, + MAX_SCALE, + ); + + return { + x: leftInset + availableWidth / 2 - (bounds.x + boundsWidth / 2) * scale, + y: topInset + availableHeight / 2 - (bounds.y + boundsHeight / 2) * scale, + scale, + }; +} + +export function scaleViewportFromScreenPoint({ + viewport, + nextScale, + screenPoint, +}: { + viewport: CanvasViewport; + nextScale: number; + screenPoint: CanvasPoint | null; +}) { + const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); + if (!screenPoint) { + return { + ...viewport, + scale, + }; + } + const worldX = (screenPoint.x - viewport.x) / viewport.scale; + const worldY = (screenPoint.y - viewport.y) / viewport.scale; + return { + x: screenPoint.x - worldX * scale, + y: screenPoint.y - worldY * scale, + scale, + }; +} + +export function panViewportFromWheel({ + viewport, + deltaX, + deltaY, +}: { + viewport: CanvasViewport; + deltaX: number; + deltaY: number; +}) { + return { + ...viewport, + x: viewport.x - deltaX, + y: viewport.y - deltaY, + }; +} + +export function zoomViewportFromWheel({ + viewport, + deltaY, + screenPoint, +}: { + viewport: CanvasViewport; + deltaY: number; + screenPoint: CanvasPoint; +}) { + return scaleViewportFromScreenPoint({ + viewport, + nextScale: viewport.scale * (deltaY > 0 ? 0.9 : 1.1), + screenPoint, + }); +} + +export function moveViewportFromPan( + dragState: Extract, + pointer: CanvasPoint, +) { + return { + ...dragState.startViewport, + x: dragState.startViewport.x + pointer.x - dragState.startClientX, + y: dragState.startViewport.y + pointer.y - dragState.startClientY, + }; +} + +export function moveGenerationFrameFromDrag( + dragState: Extract, + pointer: CanvasPoint, +) { + return { + x: + dragState.startFrameX + + (pointer.x - dragState.startClientX) / dragState.startScale, + y: + dragState.startFrameY + + (pointer.y - dragState.startClientY) / dragState.startScale, + }; +} + +function getMarqueeRect({ + marquee, + currentPoint, +}: { + marquee: CanvasMarqueeState; + currentPoint: CanvasPoint; +}) { + return { + left: Math.min(marquee.startX, currentPoint.x), + right: Math.max(marquee.startX, currentPoint.x), + top: Math.min(marquee.startY, currentPoint.y), + bottom: Math.max(marquee.startY, currentPoint.y), + }; +} + +function intersectsScreenRect({ + x, + y, + width, + height, + viewport, + rect, +}: { + x: number; + y: number; + width: number; + height: number; + viewport: CanvasViewport; + rect: { left: number; right: number; top: number; bottom: number }; +}) { + const itemLeft = viewport.x + x * viewport.scale; + const itemTop = viewport.y + y * viewport.scale; + const itemRight = itemLeft + width * viewport.scale; + const itemBottom = itemTop + height * viewport.scale; + return ( + itemLeft <= rect.right && + itemRight >= rect.left && + itemTop <= rect.bottom && + itemBottom >= rect.top + ); +} + +function getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds = [], + excludedGenerationDialogIds = [], +}: { + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; + excludedLayerIds?: string[]; + excludedGenerationDialogIds?: string[]; +}) { + const layerSnapItems = layers + .filter((layer) => !excludedLayerIds.includes(layer.id)) + .map((layer) => ({ + id: layer.id, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + hidden: layer.hidden, + })); + const generationSnapItems = generationDialogs.flatMap((dialog) => { + if ( + !dialog.placeholder || + excludedGenerationDialogIds.includes(dialog.id) + ) { + return []; + } + return [ + { + id: getCanvasGenerationSelectionId(dialog.id), + x: dialog.placeholder.x, + y: dialog.placeholder.y, + width: dialog.placeholder.width, + height: dialog.placeholder.height, + }, + ]; + }); + return [...layerSnapItems, ...generationSnapItems]; +} + +export function selectLayersInsideMarquee({ + marquee, + currentPoint, + layers, + viewport, +}: { + marquee: CanvasMarqueeState; + currentPoint: CanvasPoint; + layers: CanvasLayer[]; + viewport: CanvasViewport; +}) { + const rect = getMarqueeRect({ marquee, currentPoint }); + + return layers + .filter((layer) => { + return intersectsScreenRect({ + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + viewport, + rect, + }); + }) + .map((layer) => layer.id); +} + +export function selectCanvasObjectsInsideMarquee({ + marquee, + currentPoint, + layers, + generationDialogs, + viewport, +}: { + marquee: CanvasMarqueeState; + currentPoint: CanvasPoint; + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; + viewport: CanvasViewport; +}) { + const rect = getMarqueeRect({ marquee, currentPoint }); + const layerIds = selectLayersInsideMarquee({ + marquee, + currentPoint, + layers, + viewport, + }); + const generationDialogIds = generationDialogs + .filter((dialog) => { + const placeholder = dialog.placeholder; + if (!placeholder) { + return false; + } + return intersectsScreenRect({ + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + viewport, + rect, + }); + }) + .map((dialog) => getCanvasGenerationSelectionId(dialog.id)); + return [...layerIds, ...generationDialogIds]; +} + +export function moveLayersFromDrag({ + dragState, + layers, + generationDialogs = [], + pointer, +}: { + dragState: Extract; + layers: CanvasLayer[]; + generationDialogs?: CanvasGenerationDialogState[]; + pointer: CanvasPoint; +}): CanvasLayerMoveResult | null { + const movingLayer = layers.find((layer) => layer.id === dragState.layerId); + if (!movingLayer) { + return null; + } + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + const proposedX = dragState.startLayerX + deltaX; + const proposedY = dragState.startLayerY + deltaY; + const snapped = resolveSnappedItemPosition( + movingLayer, + proposedX, + proposedY, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: dragState.generationDialogIds ?? [], + }), + dragState.startScale, + ); + const snapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; + + return { + snapGuide: snapped.guide, + snapOffset, + layers: layers.map((layer) => + dragState.layerIds.includes(layer.id) + ? (() => { + const startLayer = dragState.startLayers.find( + (item) => item.id === layer.id, + ); + if (!startLayer) { + return layer; + } + if (layer.id === dragState.layerId) { + return { + ...layer, + x: snapped.x, + y: snapped.y, + }; + } + return { + ...layer, + x: startLayer.x + deltaX + (snapped.x - proposedX), + y: startLayer.y + deltaY + (snapped.y - proposedY), + }; + })() + : layer, + ), + }; +} + +export function moveGenerationFramesFromDrag({ + dragState, + generationDialogs, + layers = [], + snapOffset, + pointer, +}: { + dragState: Extract; + generationDialogs: CanvasGenerationDialogState[]; + layers?: CanvasLayer[]; + snapOffset?: CanvasPoint | null; + pointer: CanvasPoint; +}): CanvasGenerationFrameMoveResult { + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + const targetDialogIds = + dragState.kind === 'generation-frame' + ? dragState.dialogIds + : (dragState.generationDialogIds ?? []); + if (!targetDialogIds.length) { + return { + generationDialogs, + snapGuide: null, + snapOffset: { x: 0, y: 0 }, + }; + } + const startFrames = + dragState.kind === 'generation-frame' + ? dragState.startFrames + : (dragState.startGenerationFrames ?? []); + const movingDialogId = + dragState.kind === 'generation-frame' + ? dragState.dialogId + : targetDialogIds[0]; + const movingDialog = generationDialogs.find( + (dialog) => dialog.id === movingDialogId, + ); + const movingPlaceholder = movingDialog?.placeholder; + const movingStartFrame = startFrames.find( + (frame) => frame.id === movingDialogId, + ); + const proposedX = (movingStartFrame?.x ?? 0) + deltaX; + const proposedY = (movingStartFrame?.y ?? 0) + deltaY; + const snapped = + snapOffset || dragState.kind === 'layer' + ? { + x: proposedX + (snapOffset?.x ?? 0), + y: proposedY + (snapOffset?.y ?? 0), + guide: null, + } + : movingDialog && movingPlaceholder && movingStartFrame + ? resolveSnappedItemPosition( + { + id: getCanvasGenerationSelectionId(movingDialog.id), + x: movingPlaceholder.x, + y: movingPlaceholder.y, + width: movingPlaceholder.width, + height: movingPlaceholder.height, + }, + proposedX, + proposedY, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: targetDialogIds, + }), + dragState.startScale, + ) + : { x: proposedX, y: proposedY, guide: null }; + const appliedSnapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; + return { + snapGuide: snapped.guide, + snapOffset: appliedSnapOffset, + generationDialogs: generationDialogs.map((dialog) => { + if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) { + return dialog; + } + const startFrame = startFrames.find((frame) => frame.id === dialog.id); + if (!startFrame) { + return dialog; + } + return { + ...dialog, + placeholder: { + ...dialog.placeholder, + x: startFrame.x + deltaX + appliedSnapOffset.x, + y: startFrame.y + deltaY + appliedSnapOffset.y, + }, + }; + }), + }; +} + +export function moveDragSelectedLayers({ + dragState, + layers, + snapOffset = { x: 0, y: 0 }, + pointer, +}: { + dragState: Extract; + layers: CanvasLayer[]; + snapOffset?: CanvasPoint; + pointer: CanvasPoint; +}) { + if (!dragState.layerIds.length) { + return layers; + } + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + return layers.map((layer) => { + if (!dragState.layerIds.includes(layer.id)) { + return layer; + } + const startLayer = dragState.startLayers.find( + (item) => item.id === layer.id, + ); + if (!startLayer) { + return layer; + } + return { + ...layer, + x: startLayer.x + deltaX + snapOffset.x, + y: startLayer.y + deltaY + snapOffset.y, + }; + }); +} + +export function createMinimapModel({ + layers, + viewport, + canvasSize, +}: { + layers: CanvasLayer[]; + viewport: CanvasViewport; + canvasSize: CanvasSize; +}): StageMinimapModel | null { + const layerBounds = getLayerBounds(layers); + if (!layerBounds) { + return null; + } + + const visibleBounds = { + minX: (0 - viewport.x) / viewport.scale, + minY: (0 - viewport.y) / viewport.scale, + maxX: (canvasSize.width - viewport.x) / viewport.scale, + maxY: (canvasSize.height - viewport.y) / viewport.scale, + }; + const bounds = { + minX: Math.min(layerBounds.minX, visibleBounds.minX), + minY: Math.min(layerBounds.minY, visibleBounds.minY), + maxX: Math.max(layerBounds.maxX, visibleBounds.maxX), + maxY: Math.max(layerBounds.maxY, visibleBounds.maxY), + }; + const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); + const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); + const scale = Math.min( + (MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth, + (MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight, + ); + const projectRect = (rect: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) => ({ + left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale, + top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale, + width: Math.max(2, (rect.maxX - rect.minX) * scale), + height: Math.max(2, (rect.maxY - rect.minY) * scale), + }); + + return { + bounds, + scale, + layers: layers.map((layer) => ({ + id: layer.id, + title: layer.title, + rect: projectRect({ + minX: layer.x, + minY: layer.y, + maxX: layer.x + layer.width, + maxY: layer.y + layer.height, + }), + })), + viewport: projectRect(visibleBounds), + }; +} + +export function moveViewportFromMinimapPointer({ + viewport, + canvasSize, + minimapModel, + pointer, +}: { + viewport: CanvasViewport; + canvasSize: CanvasSize; + minimapModel: StageMinimapModel; + pointer: CanvasPoint; +}) { + const localX = clamp(pointer.x, 0, MINIMAP_SIZE.width); + const localY = clamp(pointer.y, 0, MINIMAP_SIZE.height); + const worldX = + minimapModel.bounds.minX + (localX - MINIMAP_PADDING) / minimapModel.scale; + const worldY = + minimapModel.bounds.minY + (localY - MINIMAP_PADDING) / minimapModel.scale; + + return { + ...viewport, + x: canvasSize.width / 2 - worldX * viewport.scale, + y: canvasSize.height / 2 - worldY * viewport.scale, + }; +} + +export function moveViewportFromMinimapDrag( + dragState: Extract, + pointer: CanvasPoint, +) { + const deltaWorldX = + ((pointer.x - dragState.startClientX) / dragState.minimapScale) * + MINIMAP_DRAG_SENSITIVITY; + const deltaWorldY = + ((pointer.y - dragState.startClientY) / dragState.minimapScale) * + MINIMAP_DRAG_SENSITIVITY; + + return { + ...dragState.startViewport, + x: dragState.startViewport.x - deltaWorldX * dragState.startViewport.scale, + y: dragState.startViewport.y - deltaWorldY * dragState.startViewport.scale, + }; +} + +export function getDefaultCanvasScreenCenter(canvasSize = DEFAULT_CANVAS_SIZE) { + return { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }; +} diff --git a/packages/image-canvas-core/src/layerCommands.ts b/packages/image-canvas-core/src/layerCommands.ts new file mode 100644 index 000000000..746506b1d --- /dev/null +++ b/packages/image-canvas-core/src/layerCommands.ts @@ -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, + }, + ); +} diff --git a/packages/image-canvas-core/src/layerTransform.ts b/packages/image-canvas-core/src/layerTransform.ts new file mode 100644 index 000000000..4dd9e3b3f --- /dev/null +++ b/packages/image-canvas-core/src/layerTransform.ts @@ -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>, +) { + 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), + }; + }); +} diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts new file mode 100644 index 000000000..3deeac6f8 --- /dev/null +++ b/packages/image-canvas-core/src/model.ts @@ -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; +} diff --git a/packages/image-canvas-core/src/overlays.ts b/packages/image-canvas-core/src/overlays.ts new file mode 100644 index 000000000..fd9fde5a1 --- /dev/null +++ b/packages/image-canvas-core/src/overlays.ts @@ -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; + +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' + ); +} diff --git a/packages/image-canvas-core/src/ports.ts b/packages/image-canvas-core/src/ports.ts new file mode 100644 index 000000000..7c6502382 --- /dev/null +++ b/packages/image-canvas-core/src/ports.ts @@ -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 = + | { 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>; + createDraft( + scope: ImageCanvasHostScope, + ): Promise>; + updateDraft(input: { + scope: ImageCanvasHostScope; + expectedDraftRevision: number; + status: 'editing' | 'generating' | 'cancelled'; + canvas: ImageCanvasDraft['canvas']; + generations: ImageCanvasGenerationRecord[]; + }): Promise>; + discardDraft(input: { + scope: ImageCanvasHostScope; + expectedDraftRevision: number; + }): Promise>; +} + +export interface ImageCanvasAssetPort { + importImages(input: { + scope: ImageCanvasHostScope; + expectedDraftRevision: number; + images: ImageCanvasHostInputImage[]; + }): Promise>; + 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 { + return { status: 'unsupported-capability', capability, message }; +} diff --git a/packages/image-canvas-core/src/selection.ts b/packages/image-canvas-core/src/selection.ts new file mode 100644 index 000000000..ef3d0c77d --- /dev/null +++ b/packages/image-canvas-core/src/selection.ts @@ -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); +} diff --git a/packages/image-canvas-core/src/sharedCanvasCore.test.ts b/packages/image-canvas-core/src/sharedCanvasCore.test.ts new file mode 100644 index 000000000..3b9fbf1c4 --- /dev/null +++ b/packages/image-canvas-core/src/sharedCanvasCore.test.ts @@ -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 { + 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', + }); + }); +}); diff --git a/packages/image-canvas-core/src/stageController.ts b/packages/image-canvas-core/src/stageController.ts new file mode 100644 index 000000000..85647bedf --- /dev/null +++ b/packages/image-canvas-core/src/stageController.ts @@ -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; + 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, + }; +} diff --git a/packages/image-canvas-core/src/stageInteractions.ts b/packages/image-canvas-core/src/stageInteractions.ts new file mode 100644 index 000000000..477d776d1 --- /dev/null +++ b/packages/image-canvas-core/src/stageInteractions.ts @@ -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 { + 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; +} { + 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 | 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 | 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 { + return { + kind: 'minimap', + pointerId, + startClientX: pointer.x, + startClientY: pointer.y, + startViewport: { ...viewport }, + minimapScale, + moved: false, + }; +} + +export function updateMinimapDragMovement( + dragState: Extract, + pointer: CanvasPoint, +): Extract { + 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; +} diff --git a/packages/image-canvas-core/src/types.ts b/packages/image-canvas-core/src/types.ts new file mode 100644 index 000000000..5bd141337 --- /dev/null +++ b/packages/image-canvas-core/src/types.ts @@ -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; +}; diff --git a/packages/image-canvas-react/package.json b/packages/image-canvas-react/package.json new file mode 100644 index 000000000..69f1d689a --- /dev/null +++ b/packages/image-canvas-react/package.json @@ -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" + } +} diff --git a/packages/image-canvas-react/src/CanvasPortal.tsx b/packages/image-canvas-react/src/CanvasPortal.tsx new file mode 100644 index 000000000..a4d9d45bd --- /dev/null +++ b/packages/image-canvas-react/src/CanvasPortal.tsx @@ -0,0 +1,41 @@ +import { createContext, type ReactNode, useContext } from 'react'; +import { createPortal } from 'react-dom'; + +const CanvasPortalRootContext = createContext(null); + +export function CanvasPortalProvider({ + children, + root, +}: { + children: ReactNode; + root: HTMLElement | null; +}) { + return ( + + {children} + + ); +} + +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( +
+ {children} +
, + portalRoot, + ); +} diff --git a/packages/image-canvas-react/src/CanvasViewport.tsx b/packages/image-canvas-react/src/CanvasViewport.tsx new file mode 100644 index 000000000..35d8742ec --- /dev/null +++ b/packages/image-canvas-react/src/CanvasViewport.tsx @@ -0,0 +1,51 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; + +import type { CanvasTool } from '@genarrative/image-canvas-core'; + +export type CanvasViewportProps = HTMLAttributes & { + children: ReactNode; + backgroundColor?: string; + isInteractionPaused?: boolean; + isPanning?: boolean; + tool?: CanvasTool; +}; + +export const CanvasViewport = forwardRef( + function CanvasViewport( + { + children, + backgroundColor, + className = '', + isInteractionPaused = false, + isPanning = false, + style, + tool = 'select', + ...props + }, + ref, + ) { + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx new file mode 100644 index 000000000..01ce6486e --- /dev/null +++ b/packages/image-canvas-react/src/CanvasWorld.tsx @@ -0,0 +1,34 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +import { + CANVAS_WORLD_SIZE, + type CanvasViewport, +} from '@genarrative/image-canvas-core'; + +export function CanvasWorld({ + children, + className = '', + viewport, + worldSize = CANVAS_WORLD_SIZE, + style, + ...props +}: HTMLAttributes & { + children: ReactNode; + viewport: CanvasViewport; + worldSize?: number; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/image-canvas-react/src/LayerRenderer.tsx b/packages/image-canvas-react/src/LayerRenderer.tsx new file mode 100644 index 000000000..246cb1103 --- /dev/null +++ b/packages/image-canvas-react/src/LayerRenderer.tsx @@ -0,0 +1,53 @@ +import type { HTMLAttributes, KeyboardEventHandler, ReactNode } from 'react'; + +import type { CanvasLayer } from '@genarrative/image-canvas-core'; + +export function LayerRenderer({ + as = 'button', + children, + className = '', + generating = false, + hovered = false, + layer, + selected = false, + style, + ...props +}: Omit, 'style'> & { + as?: 'button' | 'div'; + children: ReactNode; + generating?: boolean; + hovered?: boolean; + layer: CanvasLayer; + selected?: boolean; + style?: HTMLAttributes['style']; + onKeyDown?: KeyboardEventHandler; +}) { + const sharedProps = { + ...props, + className: [ + 'genarrative-image-canvas__layer', + selected ? 'genarrative-image-canvas__layer--selected' : '', + hovered ? 'genarrative-image-canvas__layer--hovered' : '', + generating ? 'genarrative-image-canvas__layer--generating' : '', + layer.locked ? 'genarrative-image-canvas__layer--locked' : '', + className, + ] + .filter(Boolean) + .join(' '), + style: { + ...style, + left: layer.x, + top: layer.y, + width: layer.width, + height: layer.height, + zIndex: layer.zIndex, + display: layer.hidden ? 'none' : style?.display, + }, + children, + }; + return as === 'div' ? ( +
+ ) : ( + + ); +} diff --git a/packages/image-canvas-react/src/SelectionOverlay.tsx b/packages/image-canvas-react/src/SelectionOverlay.tsx new file mode 100644 index 000000000..6cbcf1f59 --- /dev/null +++ b/packages/image-canvas-react/src/SelectionOverlay.tsx @@ -0,0 +1,32 @@ +import type { + CanvasMarqueeState, + CanvasViewport, +} from '@genarrative/image-canvas-core'; + +export function SelectionOverlay({ + className = '', + marquee, + viewport, +}: { + className?: string; + marquee: CanvasMarqueeState | null; + viewport: CanvasViewport; +}) { + if (!marquee) return null; + return ( +