拆分图片画布浮层定位模型

新增 ImageCanvasOverlayModel 承载生成输入框、图片工具栏、快速编辑和角色动画面板定位规则

新增浮层定位模型单测覆盖锚定、边界限制和生成面板可见模式

更新图片画布前端拆分计划和 TRACKING 记录本阶段验证

精简 ImageCanvasEditorView 中的浮层坐标计算
This commit is contained in:
2026-06-17 12:46:41 +08:00
parent cdc823611b
commit d0ad8402de
5 changed files with 428 additions and 92 deletions

View File

@@ -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`

View File

@@ -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`

View File

@@ -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,

View File

@@ -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> = {}): 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> = {},
): 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);
});
});

View File

@@ -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<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
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'
);
}