diff --git a/TRACKING.md b/TRACKING.md index 53ed4d1d..485aa786 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -138,3 +138,4 @@ - 2026-06-17 前端拆分第二十阶段:新增 `ImageCanvasMetadataModalView`,把图片信息弹窗从主视图抽出,承载图片类型、生成输入、参考图、模型、分辨率、Provider、Task 和 Object 信息渲染;主视图只保留 `metadataLayer` 状态和关闭回调。同步修复未登录进入编辑器时项目 / 素材接口抢跑 401、`重置画布视图` 点击事件误传给适合视图函数的问题。新增组件单测覆盖生成图 metadata、上传图 fallback 和关闭回调,新增 hook / 主视图测试覆盖未登录不请求受保护素材 / 工程数据和重置按钮回归;主视图从 1405 行降至 1337 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后直接弹出 `账号入口`,且未登录状态下没有发起 `/api/editor/*` 请求;登录临时开发账号后 `重置画布视图` 无控制台错误,`画布背景设置` 保持 Lovart 式白色浮层,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`,上传素材可加入画布,右上角图片信息按钮可打开不透明白底元数据弹窗,关闭后 `AI画布工具栏` 仍可见。 - 2026-06-17 前端拆分第二十一阶段:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。 - 2026-06-17 前端拆分第二十二阶段:新增 `useImageCanvasAssetPointerDragBridge`,把素材卡片 pointer 拖拽到画布 / 文件夹的全局监听、拖拽激活、画布 drop 提示、文件夹高亮、移动素材、加入画布和点击抑制从主视图抽出;主视图继续负责素材库事实、画布建层、历史和工程资源持久化。同步恢复 `账号入口` 认证弹窗 portal 渲染,避免全屏画布遮住登录弹窗;`画布背景设置` 调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 显示当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;上传图片进入 `项目素材`,真实 pointer 拖拽素材到画布后图层数从 0 到 1 且 `AI画布工具栏` 保持可见;新建 `SmokeFolder` 后真实 pointer 拖拽素材到文件夹,`项目素材` 变 0、`SmokeFolder` 变 1,素材执行移动而非拷贝。控制台仅有未登录 refresh 401,登录后编辑器 API 均为 200。 +- 2026-06-17 前端拆分第二十三阶段:新增 `ImageCanvasOverlayModel`,把生成输入框锚定、图标素材生成面板宽度、选中图片工具栏边界、快速编辑面板和角色动画面板定位从主视图抽出为纯模型;主视图继续保留生成 / quick edit / 角色动画状态机和舞台编排。新增模型单测覆盖锚定优先级、生成中隐藏、icon 宽度、工具栏 clamp、quick edit / 角色动画边界和生成 dialog 模式识别;主视图从 1182 行降至 1133 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开显示 `账号入口`,登录后 `画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;素材库已有素材真实 pointer 拖入画布后图层数从 0 到 1,工具栏保持可见,截图留存于 `output/playwright/editor-overlay-model-regression-20260617.png`。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 7894715c..2ca1b736 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -191,14 +191,22 @@ - 该 hook 用独立单测覆盖拖拽激活、文件夹 drop、画布 drop、非激活拖拽清理和 pointer cancel 完成路径;主视图 DOM 测试继续覆盖真实素材库拖到画布和拖到文件夹的集成链路。 - 本阶段同步恢复认证弹窗使用 portal 渲染,避免 `/editor/canvas` 这类全屏画布内容把 `账号入口` 遮住;同时把画布背景设置面板调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。 +## 第二十三阶段模块 + +- `ImageCanvasOverlayModel.ts` + - 承载画布浮层定位纯规则:生成输入框锚定、图标素材生成面板横向扩宽、选中图片工具栏边界限制、快速编辑面板定位和角色动画面板定位。 + - 主视图继续负责生成对象、quick edit、角色动画、视口和图层状态编排,只把可纯函数验证的屏幕坐标计算移出,避免后续调整 Lovart 式浮层时继续在主视图里散落坐标公式。 + - 该模块用独立单测覆盖生成结果优先锚定、生成中 / 手动关闭隐藏输入框、icon 描述项宽度、图片工具栏 clamp、quick edit 和角色动画面板边界,以及画布生成 dialog 模式识别。 + - 本阶段主视图从 1182 行降至 1133 行;下一步若继续拆分,可在该模型基础上抽更大的 `useImageCanvasStageController`,承接舞台派生状态、右键菜单和工具切换胶水。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 -- 右键菜单定位、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。 +- 右键菜单定位、工程资源持久化、舞台派生状态和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。 ## 验证计划 -- `npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx` +- `npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 0678c404..b534a315 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,6 +1,5 @@ import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; import { - type CSSProperties, type MouseEvent as ReactMouseEvent, useCallback, useEffect, @@ -22,22 +21,25 @@ import { import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { - TOOLBAR_HALF_WIDTH, - clamp, createLayerFromAsset, isGeneratedLayer, isLayerLinkedToAsset, resolveContextMenuPosition, - serializeLayer, } from './ImageCanvasEditorModel'; import { - ICON_COMPOSER_HORIZONTAL_CHROME_REM, - ICON_COMPOSER_MIN_WIDTH_REM, - ICON_DESCRIPTION_CARD_WIDTH_REM, getGenerationFrameAriaLabel, getGenerationFrameLabel, getLayerKindLabel, } from './ImageCanvasGenerationModel'; +import { + isCanvasGenerationComposerVisible, + resolveCharacterAnimationPanelStyle, + resolveGenerationAnchor, + resolveGenerationComposerStyle, + resolveIconComposerStyle, + resolveQuickEditPanelStyle, + resolveSelectedToolbarStyle, +} from './ImageCanvasOverlayModel'; import type { AssetPointerDragState, CanvasContextMenuState, @@ -302,36 +304,21 @@ export function ImageCanvasEditorView() { [activeCanvasGenerationDialog, layers], ); const generationAnchor = activeCanvasGenerationDialog - ? (activeGenerationLayer ?? - activeCanvasGenerationDialog.placeholder ?? - null) - : null; - const generationComposerStyle = - activeCanvasGenerationDialog?.status !== 'generating' && - activeCanvasGenerationDialog?.composerOpen !== false && - generationAnchor - ? { - left: - viewport.x + - (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, - top: - viewport.y + - (generationAnchor.y + generationAnchor.height) * viewport.scale + - 10, - } - : null; - const selectedToolbarStyle = selectedLayer - ? { - left: clamp( - viewport.x + - selectedLayer.x * viewport.scale + - (selectedLayer.width * viewport.scale) / 2, - TOOLBAR_HALF_WIDTH, - Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH), - ), - top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), - } + ? resolveGenerationAnchor({ + dialog: activeCanvasGenerationDialog, + generatedLayer: activeGenerationLayer, + }) : null; + const generationComposerStyle = resolveGenerationComposerStyle({ + dialog: activeCanvasGenerationDialog, + anchor: generationAnchor, + viewport, + }); + const selectedToolbarStyle = resolveSelectedToolbarStyle({ + selectedLayer, + viewport, + canvasSize, + }); const imageContextMenuLayer = imageContextMenu ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) : null; @@ -396,10 +383,7 @@ export function ImageCanvasEditorView() { setSelectedLayerIds(layerId ? [layerId] : []); if (layerId) { setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' || - currentDialog?.mode === 'spec' || - currentDialog?.mode === 'character' || - currentDialog?.mode === 'icon' + isCanvasGenerationComposerVisible(currentDialog) ? { ...currentDialog, composerOpen: false, @@ -509,56 +493,23 @@ export function ImageCanvasEditorView() { closeGenerateComposer, clearDeletedLayerGenerationState, } = generationWorkflow; - const iconComposerStyle: CSSProperties | null = - activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle - ? { - ...generationComposerStyle, - width: `${Math.max( - ICON_COMPOSER_MIN_WIDTH_REM, - ICON_COMPOSER_HORIZONTAL_CHROME_REM + - iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM, - ).toFixed(1)}rem`, - } - : null; - const quickEditPanelStyle = - quickEditPanel && quickEditSourceLayer - ? { - left: clamp( - viewport.x + - (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * - viewport.scale, - 12, - Math.max(12, canvasSize.width - 12), - ), - top: clamp( - viewport.y + - (quickEditSourceLayer.y + quickEditSourceLayer.height) * - viewport.scale + - 12, - 12, - Math.max(12, canvasSize.height - 360), - ), - } - : null; - const characterAnimationPanelStyle = - characterAnimationPanel && characterAnimationSourceLayer - ? { - left: clamp( - viewport.x + - (characterAnimationSourceLayer.x + - characterAnimationSourceLayer.width) * - viewport.scale + - 12, - 12, - Math.max(12, canvasSize.width - 364), - ), - top: clamp( - viewport.y + characterAnimationSourceLayer.y * viewport.scale, - 12, - Math.max(12, canvasSize.height - 520), - ), - } - : null; + const iconComposerStyle = resolveIconComposerStyle({ + dialog: activeCanvasGenerationDialog, + composerStyle: generationComposerStyle, + iconDescriptionCount: iconDescriptionValues.length, + }); + const quickEditPanelStyle = resolveQuickEditPanelStyle({ + panel: quickEditPanel, + sourceLayer: quickEditSourceLayer, + viewport, + canvasSize, + }); + const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({ + panel: characterAnimationPanel, + sourceLayer: characterAnimationSourceLayer, + viewport, + canvasSize, + }); const { canvasClipboard, pasteCanvasClipboard, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts new file mode 100644 index 00000000..de6bb167 --- /dev/null +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import type { + CanvasGenerationDialogState, + CanvasLayer, + CharacterAnimationPanelState, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; +import { + isCanvasGenerationComposerVisible, + resolveCharacterAnimationPanelStyle, + resolveGenerationAnchor, + resolveGenerationComposerStyle, + resolveIconComposerStyle, + resolveQuickEditPanelStyle, + resolveSelectedToolbarStyle, +} from './ImageCanvasOverlayModel'; + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-a', + resourceId: 'resource-a', + title: '图层A', + src: 'data:image/png;base64,layer', + x: 100, + y: 80, + width: 240, + height: 120, + originalWidth: 240, + originalHeight: 120, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +function createDialog( + overrides: Partial = {}, +): CanvasGenerationDialogState { + return { + id: 'dialog-a', + mode: 'generate', + prompt: '', + status: 'idle', + placeholder: { + x: 300, + y: 200, + width: 360, + height: 260, + originalWidth: 1024, + originalHeight: 1024, + }, + ...overrides, + }; +} + +describe('ImageCanvasOverlayModel', () => { + it('anchors generation composer to generated layers before placeholders', () => { + const generatedLayer = createLayer({ x: 40, y: 60, width: 320, height: 180 }); + const dialog = createDialog({ generatedLayerId: generatedLayer.id }); + const anchor = resolveGenerationAnchor({ dialog, generatedLayer }); + + expect(anchor).toBe(generatedLayer); + expect( + resolveGenerationComposerStyle({ + dialog, + anchor, + viewport: { x: 10, y: 20, scale: 2 }, + }), + ).toEqual({ + left: 410, + top: 510, + }); + }); + + it('hides generation composer while generating or explicitly closed', () => { + const placeholder = createDialog().placeholder ?? null; + + expect( + resolveGenerationComposerStyle({ + dialog: createDialog({ status: 'generating' }), + anchor: placeholder, + viewport: { x: 0, y: 0, scale: 1 }, + }), + ).toBeNull(); + expect( + resolveGenerationComposerStyle({ + dialog: createDialog({ composerOpen: false }), + anchor: placeholder, + viewport: { x: 0, y: 0, scale: 1 }, + }), + ).toBeNull(); + }); + + it('expands icon composer width by description count', () => { + expect( + resolveIconComposerStyle({ + dialog: createDialog({ mode: 'icon' }), + composerStyle: { left: 100, top: 200 }, + iconDescriptionCount: 6, + }), + ).toEqual({ + left: 100, + top: 200, + width: '52.8rem', + }); + + expect( + resolveIconComposerStyle({ + dialog: createDialog({ mode: 'generate' }), + composerStyle: { left: 100, top: 200 }, + iconDescriptionCount: 6, + }), + ).toBeNull(); + }); + + it('keeps selected layer toolbar inside the canvas width', () => { + expect( + resolveSelectedToolbarStyle({ + selectedLayer: createLayer({ x: -500, y: -100 }), + viewport: { x: 0, y: 0, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ left: 132, top: 10 }); + + expect( + resolveSelectedToolbarStyle({ + selectedLayer: createLayer({ x: 1200, y: 100 }), + viewport: { x: 0, y: 0, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ left: 768, top: 88 }); + }); + + it('positions quick edit and character animation panels with viewport bounds', () => { + const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 }); + const quickEditPanel: QuickEditPanelState = { + sourceLayerId: sourceLayer.id, + prompt: '', + size: '1024x1024', + model: 'gpt-image-2', + status: 'idle', + }; + const characterPanel: CharacterAnimationPanelState = { + sourceLayerId: sourceLayer.id, + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + }; + + expect( + resolveQuickEditPanelStyle({ + panel: quickEditPanel, + sourceLayer, + viewport: { x: 20, y: 10, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ left: 880, top: 280 }); + expect( + resolveCharacterAnimationPanelStyle({ + panel: characterPanel, + sourceLayer, + viewport: { x: 20, y: 10, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ left: 536, top: 120 }); + }); + + it('recognizes canvas generation composer dialog modes', () => { + expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'generate' }))).toBe( + true, + ); + expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe( + true, + ); + expect( + isCanvasGenerationComposerVisible({ + mode: 'edit', + prompt: '', + status: 'idle', + }), + ).toBe(false); + expect(isCanvasGenerationComposerVisible(null)).toBe(false); + }); +}); diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts new file mode 100644 index 00000000..59492c8b --- /dev/null +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -0,0 +1,188 @@ +import { + TOOLBAR_HALF_WIDTH, + clamp, +} from './ImageCanvasEditorModel'; +import { + ICON_COMPOSER_HORIZONTAL_CHROME_REM, + ICON_COMPOSER_MIN_WIDTH_REM, + ICON_DESCRIPTION_CARD_WIDTH_REM, +} from './ImageCanvasGenerationModel'; +import type { + CanvasGenerationDialogMode, + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, + CharacterAnimationPanelState, + GenerateDialogState, + QuickEditPanelState, +} from './ImageCanvasEditorTypes'; + +type CanvasSize = { + width: number; + height: number; +}; + +type OverlayAnchor = Pick; + +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, + iconDescriptionCount, +}: { + dialog: CanvasGenerationDialogState | null; + composerStyle: CanvasOverlayStyle | null; + iconDescriptionCount: number; +}): CanvasComposerOverlayStyle | null { + if (dialog?.mode !== 'icon' || !composerStyle) { + return null; + } + return { + ...composerStyle, + width: `${Math.max( + ICON_COMPOSER_MIN_WIDTH_REM, + ICON_COMPOSER_HORIZONTAL_CHROME_REM + + iconDescriptionCount * ICON_DESCRIPTION_CARD_WIDTH_REM, + ).toFixed(1)}rem`, + }; +} + +export function resolveSelectedToolbarStyle({ + selectedLayer, + viewport, + canvasSize, +}: { + selectedLayer: CanvasLayer | null; + viewport: CanvasViewport; + canvasSize: CanvasSize; +}): CanvasOverlayStyle | null { + if (!selectedLayer) { + return null; + } + return { + left: clamp( + viewport.x + + selectedLayer.x * viewport.scale + + (selectedLayer.width * viewport.scale) / 2, + TOOLBAR_HALF_WIDTH, + Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH), + ), + top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), + }; +} + +export function resolveQuickEditPanelStyle({ + panel, + sourceLayer, + viewport, + canvasSize, +}: { + panel: QuickEditPanelState | null; + sourceLayer: CanvasLayer | null; + viewport: CanvasViewport; + canvasSize: CanvasSize; +}): CanvasOverlayStyle | null { + if (!panel || !sourceLayer) { + return null; + } + return { + left: clamp( + viewport.x + + (sourceLayer.x + sourceLayer.width / 2) * viewport.scale, + 12, + Math.max(12, canvasSize.width - 12), + ), + top: clamp( + viewport.y + + (sourceLayer.y + sourceLayer.height) * viewport.scale + + 12, + 12, + Math.max(12, canvasSize.height - 360), + ), + }; +} + +export function resolveCharacterAnimationPanelStyle({ + panel, + sourceLayer, + viewport, + canvasSize, +}: { + panel: CharacterAnimationPanelState | null; + sourceLayer: CanvasLayer | null; + viewport: CanvasViewport; + canvasSize: CanvasSize; +}): CanvasOverlayStyle | null { + if (!panel || !sourceLayer) { + return null; + } + return { + left: clamp( + viewport.x + + (sourceLayer.x + sourceLayer.width) * viewport.scale + + 12, + 12, + Math.max(12, canvasSize.width - 364), + ), + top: clamp( + viewport.y + sourceLayer.y * viewport.scale, + 12, + Math.max(12, canvasSize.height - 520), + ), + }; +} + +export function isCanvasGenerationComposerVisible( + dialog: GenerateDialogState | null, +): dialog is GenerateDialogState & { mode: CanvasGenerationDialogMode } { + return ( + dialog?.mode === 'generate' || + dialog?.mode === 'spec' || + dialog?.mode === 'character' || + dialog?.mode === 'icon' + ); +}