拆分图片画布图片信息弹窗
新增图片信息弹窗组件,承接 metadata 详情渲染和 UnifiedModal 接入 修复未登录进入编辑器时项目和素材接口抢跑 401 修复重置画布视图点击事件误传导致适合视图报错 补充图片信息弹窗、鉴权门禁和重置按钮回归测试 更新前端拆分文档和 TRACKING 浏览器回归记录
This commit is contained in:
@@ -135,3 +135,4 @@
|
|||||||
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见;普通滚轮不改变缩放,Ctrl 滚轮从 `100%` 到 `110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十七阶段:新增 `useImageCanvasViewportControls`,把视口状态、画布尺寸、小地图投影、适合视图、中心缩放、滚轮语义、坐标换算和小地图移动从主视图抽出;主视图继续保留图层拖拽、框选、生成占位拖拽、上传 drop 和历史触发时机。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏可见;普通滚轮不改变缩放,Ctrl 滚轮从 `100%` 到 `110%`;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,登录后控制台无前端 error。
|
||||||
- 2026-06-17 前端拆分第十八阶段:新增 `useImageCanvasStageInteractions`,把画布舞台 pointer 状态机、选择 / 框选、多选拖拽、生成占位拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态从主视图抽出;主视图继续保留上传 drop、右键菜单、生成提交、项目持久化和工具栏动作分流。新增 hook 单测覆盖多选拖拽、框选、临时抓手、生成占位和小地图分流;主视图从 1802 行降至 1452 行。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新和未登录上传均弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏保持可见;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;普通滚轮不改变缩放,Ctrl 滚轮从 `146%` 到 `161%`;抓手 / 文字 / 选择工具可连续切换;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,关闭对话框后占位图保留,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十八阶段:新增 `useImageCanvasStageInteractions`,把画布舞台 pointer 状态机、选择 / 框选、多选拖拽、生成占位拖拽、抓手 / Space 临时抓手 / 中键平移、小地图 click / drag 分流和吸附线状态从主视图抽出;主视图继续保留上传 drop、右键菜单、生成提交、项目持久化和工具栏动作分流。新增 hook 单测覆盖多选拖拽、框选、临时抓手、生成占位和小地图分流;主视图从 1802 行降至 1452 行。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新和未登录上传均弹出 `账号入口`,登录后素材 / 画布 / 小地图和底部工具栏保持可见;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;普通滚轮不改变缩放,Ctrl 滚轮从 `146%` 到 `161%`;抓手 / 文字 / 选择工具可连续切换;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,关闭对话框后占位图保留,登录后控制台无前端 error。
|
||||||
- 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。
|
- 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- 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/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。
|
||||||
|
- 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画布工具栏` 仍可见。
|
||||||
|
|||||||
@@ -167,6 +167,15 @@
|
|||||||
- 主视图继续提供已有能力注入:账号级素材列表、默认素材文件夹、屏幕点转画布 drop 点、素材建层、文件上传、素材移动高亮清理;drop hook 不直接创建资源、不访问 API,也不读取项目持久化状态。
|
- 主视图继续提供已有能力注入:账号级素材列表、默认素材文件夹、屏幕点转画布 drop 点、素材建层、文件上传、素材移动高亮清理;drop hook 不直接创建资源、不访问 API,也不读取项目持久化状态。
|
||||||
- 该 hook 用独立单测覆盖素材库图片拖入画布、文件拖入画布、无关拖拽不拦截和 drag leave 清理遮罩;主视图集成测试继续覆盖真实 DOM 中的“素材库拖到画布”和“文件拖到画布”路径。
|
- 该 hook 用独立单测覆盖素材库图片拖入画布、文件拖入画布、无关拖拽不拦截和 drag leave 清理遮罩;主视图集成测试继续覆盖真实 DOM 中的“素材库拖到画布”和“文件拖到画布”路径。
|
||||||
|
|
||||||
|
## 第二十阶段模块
|
||||||
|
|
||||||
|
- `ImageCanvasMetadataModalView.tsx`
|
||||||
|
- 承载图片信息弹窗渲染:图片类型、生成输入字段、参考图、模型、分辨率、Provider、Task 和 Object 信息。
|
||||||
|
- 主视图只保留 `metadataLayer` 状态和打开 / 关闭动作;图片信息的视觉结构、fallback 文案和 `UnifiedModal` 接入不再散在主视图底部。
|
||||||
|
- 该组件用独立单测覆盖生成图 metadata、上传图 fallback、参考图渲染和关闭回调;主视图集成测试继续覆盖从画布图层点击右上角信息按钮打开弹窗。
|
||||||
|
- 本阶段同步把项目 / 素材初始加载挂到 `AuthGate` 的受保护数据可访问状态之后;未登录进入编辑器只拉起 `账号入口`,不再抢跑 `/api/editor/*` 造成素材或工程读取 401 噪声。
|
||||||
|
- `重置画布视图` 按钮必须显式调用 `onFitLayers()`,不能把 React click event 作为目标图层数组透传给适合视图逻辑。
|
||||||
|
|
||||||
## 后续阶段
|
## 后续阶段
|
||||||
|
|
||||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||||
@@ -174,7 +183,7 @@
|
|||||||
|
|
||||||
## 验证计划
|
## 验证计划
|
||||||
|
|
||||||
- `npm run test -- 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/ImageCanvasEditorView.test.tsx`
|
- `npm run test -- 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 typecheck`
|
||||||
- `npm run check:encoding`
|
- `npm run check:encoding`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|||||||
@@ -397,6 +397,35 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthUiContext.Provider
|
||||||
|
value={createAuthValue({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
publicUserCode: 'U001',
|
||||||
|
displayName: '测试用户',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: '138****0000',
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
},
|
||||||
|
canAccessProtectedData: true,
|
||||||
|
openLoginModal,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ImageCanvasEditorView />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
||||||
<ImageCanvasEditorView />
|
<ImageCanvasEditorView />
|
||||||
@@ -406,6 +435,10 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
expect(typeof openLoginModal.mock.calls[0]?.[0]).toBe('function');
|
||||||
|
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
|
||||||
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||||
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renames the current project from the canvas topbar', async () => {
|
it('renames the current project from the canvas topbar', async () => {
|
||||||
@@ -976,13 +1009,14 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
new File(['image'], '登录后上传.png', { type: 'image/png' }),
|
new File(['image'], '登录后上传.png', { type: 'image/png' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
expect(openLoginModal).toHaveBeenCalled();
|
||||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
|
screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
const resumeUpload = openLoginModal.mock.calls[0]?.[0];
|
const resumeUpload =
|
||||||
|
openLoginModal.mock.calls[openLoginModal.mock.calls.length - 1]?.[0];
|
||||||
expect(typeof resumeUpload).toBe('function');
|
expect(typeof resumeUpload).toBe('function');
|
||||||
rerender(
|
rerender(
|
||||||
<AuthUiContext.Provider
|
<AuthUiContext.Provider
|
||||||
@@ -2038,6 +2072,14 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resets the canvas view without forwarding the click event to fit layers', () => {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
|
it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => {
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
|
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||||
|
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
||||||
import {
|
import {
|
||||||
getCanvasLayersByIds,
|
getCanvasLayersByIds,
|
||||||
resolveContextTargetLayerIds,
|
resolveContextTargetLayerIds,
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
||||||
ICON_COMPOSER_MIN_WIDTH_REM,
|
ICON_COMPOSER_MIN_WIDTH_REM,
|
||||||
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
||||||
formatLayerImageType,
|
|
||||||
getGenerationFrameAriaLabel,
|
getGenerationFrameAriaLabel,
|
||||||
getGenerationFrameLabel,
|
getGenerationFrameLabel,
|
||||||
getLayerKindLabel,
|
getLayerKindLabel,
|
||||||
@@ -83,6 +82,7 @@ export function ImageCanvasEditorView() {
|
|||||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
||||||
const authUiRef = useRef(authUi);
|
const authUiRef = useRef(authUi);
|
||||||
|
const hasRequestedLoginRef = useRef(false);
|
||||||
const layerCounterRef = useRef(0);
|
const layerCounterRef = useRef(0);
|
||||||
const layersRef = useRef<CanvasLayer[]>([]);
|
const layersRef = useRef<CanvasLayer[]>([]);
|
||||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||||
@@ -149,6 +149,20 @@ export function ImageCanvasEditorView() {
|
|||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
|
||||||
|
hasRequestedLoginRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasRequestedLoginRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasRequestedLoginRef.current = true;
|
||||||
|
authUi.openLoginModal(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}, [authUi]);
|
||||||
const {
|
const {
|
||||||
projectTitle,
|
projectTitle,
|
||||||
setProjectTitle,
|
setProjectTitle,
|
||||||
@@ -260,6 +274,7 @@ export function ImageCanvasEditorView() {
|
|||||||
handleAssetMarqueePointerUp,
|
handleAssetMarqueePointerUp,
|
||||||
} = useImageCanvasAssetLibrary({
|
} = useImageCanvasAssetLibrary({
|
||||||
assetListRef,
|
assetListRef,
|
||||||
|
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
||||||
});
|
});
|
||||||
@@ -440,6 +455,7 @@ export function ImageCanvasEditorView() {
|
|||||||
setters: projectPersistenceSetters,
|
setters: projectPersistenceSetters,
|
||||||
layers,
|
layers,
|
||||||
viewport,
|
viewport,
|
||||||
|
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -1323,81 +1339,10 @@ export function ImageCanvasEditorView() {
|
|||||||
</ImageCanvasStageView>
|
</ImageCanvasStageView>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnifiedModal
|
<ImageCanvasMetadataModalView
|
||||||
open={Boolean(metadataLayer)}
|
layer={metadataLayer}
|
||||||
title={metadataLayer ? `${metadataLayer.title}图片信息` : '图片信息'}
|
|
||||||
size="sm"
|
|
||||||
closeLabel="关闭图片信息"
|
|
||||||
onClose={() => setMetadataLayer(null)}
|
onClose={() => setMetadataLayer(null)}
|
||||||
panelClassName="image-canvas-editor__metadata-dialog"
|
/>
|
||||||
bodyClassName="image-canvas-editor__metadata-body"
|
|
||||||
>
|
|
||||||
{metadataLayer ? (
|
|
||||||
<dl className="image-canvas-editor__metadata-grid">
|
|
||||||
<dt>图片类型</dt>
|
|
||||||
<dd>{formatLayerImageType(metadataLayer)}</dd>
|
|
||||||
<dt>生成输入</dt>
|
|
||||||
<dd className="image-canvas-editor__metadata-inputs">
|
|
||||||
{metadataLayer.generationInputs?.fields.length ||
|
|
||||||
metadataLayer.generationInputs?.references.length ? (
|
|
||||||
<>
|
|
||||||
{metadataLayer.generationInputs.fields.map((field) => (
|
|
||||||
<div
|
|
||||||
key={`${field.title}-${field.value}`}
|
|
||||||
className="image-canvas-editor__metadata-input-field"
|
|
||||||
>
|
|
||||||
<span className="image-canvas-editor__metadata-input-title">
|
|
||||||
{field.title}
|
|
||||||
</span>
|
|
||||||
<span>{field.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{metadataLayer.generationInputs.references.length ? (
|
|
||||||
<div className="image-canvas-editor__metadata-reference-list">
|
|
||||||
{metadataLayer.generationInputs.references.map(
|
|
||||||
(reference) => (
|
|
||||||
<div
|
|
||||||
key={`${reference.title}-${reference.label}-${reference.src}`}
|
|
||||||
className="image-canvas-editor__metadata-reference-card"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={reference.src}
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="image-canvas-editor__metadata-reference-copy">
|
|
||||||
<span className="image-canvas-editor__metadata-input-title">
|
|
||||||
{reference.title}
|
|
||||||
</span>
|
|
||||||
<span>{reference.label}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
<dt>Model</dt>
|
|
||||||
<dd>{metadataLayer.model ?? '-'}</dd>
|
|
||||||
<dt>Resolution</dt>
|
|
||||||
<dd>
|
|
||||||
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
|
|
||||||
</dd>
|
|
||||||
<dt>Provider</dt>
|
|
||||||
<dd>{metadataLayer.provider ?? '-'}</dd>
|
|
||||||
<dt>Task</dt>
|
|
||||||
<dd>{metadataLayer.taskId ?? '-'}</dd>
|
|
||||||
<dt>Object</dt>
|
|
||||||
<dd>
|
|
||||||
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
) : null}
|
|
||||||
</UnifiedModal>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
|
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
||||||
|
|
||||||
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||||
|
return {
|
||||||
|
id: 'layer-1',
|
||||||
|
resourceId: 'resource-1',
|
||||||
|
title: '生成主图',
|
||||||
|
src: 'data:image/png;base64,layer',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 1024,
|
||||||
|
originalHeight: 768,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'generated',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImageCanvasMetadataModalView', () => {
|
||||||
|
it('renders generated layer metadata with generation inputs and references', () => {
|
||||||
|
render(
|
||||||
|
<ImageCanvasMetadataModalView
|
||||||
|
layer={createLayer({
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
provider: 'VectorEngine',
|
||||||
|
taskId: 'task-123',
|
||||||
|
objectKey: 'generated/object.png',
|
||||||
|
generationInputs: {
|
||||||
|
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
title: '参考图',
|
||||||
|
label: '角色立绘',
|
||||||
|
src: 'data:image/png;base64,reference',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '生成主图图片信息' });
|
||||||
|
|
||||||
|
expect(within(dialog).getByText('生成图片')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('生成提示词')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('参考图')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('角色立绘')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('gpt-image-2')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('VectorEngine')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('task-123')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('generated/object.png')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders upload fallback values and closes through the modal shell', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<ImageCanvasMetadataModalView
|
||||||
|
layer={createLayer({
|
||||||
|
title: '上传素材',
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
model: null,
|
||||||
|
provider: null,
|
||||||
|
taskId: null,
|
||||||
|
objectKey: null,
|
||||||
|
assetObjectId: 'asset-object-1',
|
||||||
|
generationInputs: null,
|
||||||
|
})}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '上传素材图片信息' });
|
||||||
|
|
||||||
|
expect(within(dialog).getByText('上传图片')).toBeTruthy();
|
||||||
|
expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(within(dialog).getByText('asset-object-1')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片信息' }));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a dialog when no layer is selected', () => {
|
||||||
|
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: '图片信息' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/components/image-editor/ImageCanvasMetadataModalView.tsx
Normal file
83
src/components/image-editor/ImageCanvasMetadataModalView.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
|
import { formatLayerImageType } from './ImageCanvasGenerationModel';
|
||||||
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
|
type ImageCanvasMetadataModalViewProps = {
|
||||||
|
layer: CanvasLayer | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageCanvasMetadataModalView({
|
||||||
|
layer,
|
||||||
|
onClose,
|
||||||
|
}: ImageCanvasMetadataModalViewProps) {
|
||||||
|
return (
|
||||||
|
<UnifiedModal
|
||||||
|
open={Boolean(layer)}
|
||||||
|
title={layer ? `${layer.title}图片信息` : '图片信息'}
|
||||||
|
size="sm"
|
||||||
|
closeLabel="关闭图片信息"
|
||||||
|
onClose={onClose}
|
||||||
|
panelClassName="image-canvas-editor__metadata-dialog"
|
||||||
|
bodyClassName="image-canvas-editor__metadata-body"
|
||||||
|
>
|
||||||
|
{layer ? (
|
||||||
|
<dl className="image-canvas-editor__metadata-grid">
|
||||||
|
<dt>图片类型</dt>
|
||||||
|
<dd>{formatLayerImageType(layer)}</dd>
|
||||||
|
<dt>生成输入</dt>
|
||||||
|
<dd className="image-canvas-editor__metadata-inputs">
|
||||||
|
{layer.generationInputs?.fields.length ||
|
||||||
|
layer.generationInputs?.references.length ? (
|
||||||
|
<>
|
||||||
|
{layer.generationInputs.fields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={`${field.title}-${field.value}`}
|
||||||
|
className="image-canvas-editor__metadata-input-field"
|
||||||
|
>
|
||||||
|
<span className="image-canvas-editor__metadata-input-title">
|
||||||
|
{field.title}
|
||||||
|
</span>
|
||||||
|
<span>{field.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{layer.generationInputs.references.length ? (
|
||||||
|
<div className="image-canvas-editor__metadata-reference-list">
|
||||||
|
{layer.generationInputs.references.map((reference) => (
|
||||||
|
<div
|
||||||
|
key={`${reference.title}-${reference.label}-${reference.src}`}
|
||||||
|
className="image-canvas-editor__metadata-reference-card"
|
||||||
|
>
|
||||||
|
<img src={reference.src} alt="" aria-hidden="true" />
|
||||||
|
<span className="image-canvas-editor__metadata-reference-copy">
|
||||||
|
<span className="image-canvas-editor__metadata-input-title">
|
||||||
|
{reference.title}
|
||||||
|
</span>
|
||||||
|
<span>{reference.label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt>Model</dt>
|
||||||
|
<dd>{layer.model ?? '-'}</dd>
|
||||||
|
<dt>Resolution</dt>
|
||||||
|
<dd>
|
||||||
|
{layer.originalWidth} x {layer.originalHeight} px
|
||||||
|
</dd>
|
||||||
|
<dt>Provider</dt>
|
||||||
|
<dd>{layer.provider ?? '-'}</dd>
|
||||||
|
<dt>Task</dt>
|
||||||
|
<dd>{layer.taskId ?? '-'}</dd>
|
||||||
|
<dt>Object</dt>
|
||||||
|
<dd>{layer.objectKey ?? layer.assetObjectId ?? '-'}</dd>
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</UnifiedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -784,7 +784,7 @@ export function ImageCanvasStageView({
|
|||||||
label="重置画布视图"
|
label="重置画布视图"
|
||||||
title="重置画布视图"
|
title="重置画布视图"
|
||||||
icon={RotateCcw}
|
icon={RotateCcw}
|
||||||
onClick={onFitLayers}
|
onClick={() => onFitLayers()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -65,15 +65,18 @@ function createAssetSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssetLibraryHarness({
|
function AssetLibraryHarness({
|
||||||
|
canAccessProtectedData = true,
|
||||||
openEditorLoginModal = defaultOpenEditorLoginModal,
|
openEditorLoginModal = defaultOpenEditorLoginModal,
|
||||||
onDeleteAssets = defaultOnDeleteAssets,
|
onDeleteAssets = defaultOnDeleteAssets,
|
||||||
}: {
|
}: {
|
||||||
|
canAccessProtectedData?: boolean;
|
||||||
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
||||||
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const assetLibrary = useImageCanvasAssetLibrary({
|
const assetLibrary = useImageCanvasAssetLibrary({
|
||||||
assetListRef,
|
assetListRef,
|
||||||
|
canAccessProtectedData,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
onDeleteAssets,
|
onDeleteAssets,
|
||||||
});
|
});
|
||||||
@@ -259,6 +262,15 @@ describe('useImageCanvasAssetLibrary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not request the protected asset library before login is available', () => {
|
||||||
|
render(<AssetLibraryHarness canAccessProtectedData={false} />);
|
||||||
|
|
||||||
|
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('folders').textContent).toBe(
|
||||||
|
'project:项目素材:false',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a local folder and replaces it with the persisted folder id', async () => {
|
it('creates a local folder and replaces it with the persisted folder id', async () => {
|
||||||
let resolveCreateFolder: (
|
let resolveCreateFolder: (
|
||||||
folder: Awaited<ReturnType<typeof createEditorAssetFolderMock>>,
|
folder: Awaited<ReturnType<typeof createEditorAssetFolderMock>>,
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ function isEditorAuthError(error: unknown) {
|
|||||||
|
|
||||||
export function useImageCanvasAssetLibrary({
|
export function useImageCanvasAssetLibrary({
|
||||||
assetListRef,
|
assetListRef,
|
||||||
|
canAccessProtectedData,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
onDeleteAssets,
|
onDeleteAssets,
|
||||||
}: {
|
}: {
|
||||||
assetListRef: RefObject<HTMLDivElement | null>;
|
assetListRef: RefObject<HTMLDivElement | null>;
|
||||||
|
canAccessProtectedData: boolean;
|
||||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||||
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -94,6 +96,9 @@ export function useImageCanvasAssetLibrary({
|
|||||||
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
|
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canAccessProtectedData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
loadEditorAssetLibrary()
|
loadEditorAssetLibrary()
|
||||||
.then((library) => {
|
.then((library) => {
|
||||||
@@ -118,7 +123,7 @@ export function useImageCanvasAssetLibrary({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [openEditorLoginModal]);
|
}, [canAccessProtectedData, openEditorLoginModal]);
|
||||||
|
|
||||||
const resolveAssetFolderId = useCallback(
|
const resolveAssetFolderId = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ function createLayer(id: string): CanvasLayer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPersistenceHarness() {
|
function ProjectPersistenceHarness({
|
||||||
|
canAccessProtectedData = true,
|
||||||
|
}: {
|
||||||
|
canAccessProtectedData?: boolean;
|
||||||
|
}) {
|
||||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -79,6 +83,7 @@ function ProjectPersistenceHarness() {
|
|||||||
},
|
},
|
||||||
layers,
|
layers,
|
||||||
viewport,
|
viewport,
|
||||||
|
canAccessProtectedData,
|
||||||
openEditorLoginModal: vi.fn(),
|
openEditorLoginModal: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,4 +174,12 @@ describe('useImageCanvasProjectPersistence', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not load protected project data before login is available', () => {
|
||||||
|
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||||
|
|
||||||
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||||
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('project-id').textContent).toBe('-');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ export function useImageCanvasProjectPersistence({
|
|||||||
setters,
|
setters,
|
||||||
layers,
|
layers,
|
||||||
viewport,
|
viewport,
|
||||||
|
canAccessProtectedData,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
}: {
|
}: {
|
||||||
refs: ImageCanvasProjectPersistenceRefs;
|
refs: ImageCanvasProjectPersistenceRefs;
|
||||||
setters: ImageCanvasProjectPersistenceSetters;
|
setters: ImageCanvasProjectPersistenceSetters;
|
||||||
layers: CanvasLayer[];
|
layers: CanvasLayer[];
|
||||||
viewport: CanvasViewport;
|
viewport: CanvasViewport;
|
||||||
|
canAccessProtectedData: boolean;
|
||||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const projectIdRef = useRef<string | null>(null);
|
const projectIdRef = useRef<string | null>(null);
|
||||||
@@ -145,6 +147,10 @@ export function useImageCanvasProjectPersistence({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canAccessProtectedData) {
|
||||||
|
setIsProjectReady(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const projectIdFromQuery =
|
const projectIdFromQuery =
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined'
|
||||||
@@ -202,7 +208,12 @@ export function useImageCanvasProjectPersistence({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [createProjectResourceForLayer, openEditorLoginModal, setters]);
|
}, [
|
||||||
|
canAccessProtectedData,
|
||||||
|
createProjectResourceForLayer,
|
||||||
|
openEditorLoginModal,
|
||||||
|
setters,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !isProjectReady) {
|
if (!projectId || !isProjectReady) {
|
||||||
|
|||||||
Reference in New Issue
Block a user