From cdc823611b04dfb2f15ebf404f1cda8686a2369a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 12:20:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=B4=A0=E6=9D=90=E6=8B=96=E6=8B=BD=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增素材拖拽桥接 hook,承接素材拖向画布或文件夹的全局 pointer 监听 恢复认证弹窗 portal 渲染,避免全屏画布遮住账号入口 优化画布背景设置面板,补回当前色、色域、色相、预设、HEX 和恢复默认 补充素材拖拽、认证弹窗和背景面板回归测试并更新文档与 TRACKING --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 10 +- src/components/auth/AuthGate.test.tsx | 28 +- .../auth/PlatformAuthModalShell.test.tsx | 4 +- .../auth/PlatformAuthModalShell.tsx | 1 - .../ImageCanvasEditorView.test.tsx | 6 +- .../image-editor/ImageCanvasEditorView.tsx | 94 +------ .../image-editor/ImageCanvasStageView.tsx | 52 ++-- ...ImageCanvasAssetPointerDragBridge.test.tsx | 263 ++++++++++++++++++ .../useImageCanvasAssetPointerDragBridge.ts | 140 ++++++++++ src/index.css | 78 ++++-- 11 files changed, 544 insertions(+), 133 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx create mode 100644 src/components/image-editor/useImageCanvasAssetPointerDragBridge.ts diff --git a/TRACKING.md b/TRACKING.md index 47e8139d..53ed4d1d 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -137,3 +137,4 @@ - 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画布工具栏` 仍可见。 - 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。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 1a771e0c..7894715c 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -183,6 +183,14 @@ - 主视图继续保留各工作流状态和具体副作用,例如图层删除、生成对话框、规格菜单、快速编辑面板和 chrome 面板状态;快捷键 hook 只接收 ref、setter 与回调,不直接读写素材库、路由或 API。 - 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、选中图层删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。 +## 第二十二阶段模块 + +- `useImageCanvasAssetPointerDragBridge.ts` + - 承载素材库卡片 pointer 拖拽桥接:全局 `pointermove` / `pointerup` / `pointercancel` 监听、拖拽激活阈值、画布 drop 提示、文件夹移动高亮、拖到文件夹移动素材、拖到画布创建图层,以及拖拽结束后的点击抑制。 + - 主视图继续保留素材库事实、画布建层、历史捕获、工程资源持久化和素材移动 API 编排;该 hook 只作为“侧栏素材拖拽到画布或文件夹”的事件桥,不直接读写路由、API 或图层持久化。 + - 该 hook 用独立单测覆盖拖拽激活、文件夹 drop、画布 drop、非激活拖拽清理和 pointer cancel 完成路径;主视图 DOM 测试继续覆盖真实素材库拖到画布和拖到文件夹的集成链路。 + - 本阶段同步恢复认证弹窗使用 portal 渲染,避免 `/editor/canvas` 这类全屏画布内容把 `账号入口` 遮住;同时把画布背景设置面板调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。 + ## 后续阶段 - 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 @@ -190,7 +198,7 @@ ## 验证计划 -- `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 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` diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 74201447..cd28c376 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { AuthSessionSummary, AuthUser } from '../../services/authService'; @@ -250,6 +250,16 @@ function AccountPanelProbe() { ); } +function AutoOpenLoginProbe() { + const authUi = useAuthUi(); + + useEffect(() => { + authUi?.openLoginModal(); + }, [authUi]); + + return
编辑器内容
; +} + test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -266,6 +276,22 @@ test('auth gate keeps platform content visible when phone login is available', a expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); }); +test('auth gate portals page-level login requests above fullscreen content', async () => { + const { container } = render( + +
全屏画布
+ +
, + ); + + expect(await screen.findByText('编辑器内容')).toBeTruthy(); + const dialog = await screen.findByRole('dialog', { name: '账号入口' }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(dialog.parentElement?.parentElement).toBe(document.body); + expect(dialog.parentElement?.className).toContain('z-[120]'); +}); + test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => { let resolveToken!: (token: string) => void; const tokenPromise = new Promise((resolve) => { diff --git a/src/components/auth/PlatformAuthModalShell.test.tsx b/src/components/auth/PlatformAuthModalShell.test.tsx index fd566186..55765e8f 100644 --- a/src/components/auth/PlatformAuthModalShell.test.tsx +++ b/src/components/auth/PlatformAuthModalShell.test.tsx @@ -8,7 +8,7 @@ import { PlatformAuthModalShell } from './PlatformAuthModalShell'; test('renders auth modal shell with platform theme and auth card chrome', () => { const onClose = vi.fn(); - render( + const { container } = render( const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.contains(dialog)).toBe(true); expect(dialog.parentElement?.className).toContain('platform-theme--light'); expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('platform-auth-card'); diff --git a/src/components/auth/PlatformAuthModalShell.tsx b/src/components/auth/PlatformAuthModalShell.tsx index 5364639e..e7ce21e2 100644 --- a/src/components/auth/PlatformAuthModalShell.tsx +++ b/src/components/auth/PlatformAuthModalShell.tsx @@ -55,7 +55,6 @@ export function PlatformAuthModalShell({ closeVariant="platformIcon" closeOnBackdrop closeOnEscape={false} - portal={false} size={size} showHeader={showHeader} zIndexClassName={zIndexClassName} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 4a71822b..d47f77fd 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2025,7 +2025,11 @@ describe('ImageCanvasEditorView', () => { const settingsPanel = screen.getByRole('dialog', { name: '画布背景设置', }); - expect(within(settingsPanel).getByText('画布背景色')).toBeTruthy(); + expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); + expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy(); + expect( + within(settingsPanel).getByLabelText('画布背景十六进制颜色'), + ).toBeTruthy(); fireEvent.click( within(settingsPanel).getByRole('button', { name: '暖灰' }), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 3b6fa36c..0678c404 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -51,6 +51,7 @@ import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; +import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge'; import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; @@ -124,13 +125,6 @@ export function ImageCanvasEditorView() { selectedLayerIdsRef.current = selectedLayerIds; layersRef.current = layers; viewportRef.current = viewport; - const assetsRef = useRef([]); - const addAssetLayerRef = useRef< - (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void - >(() => {}); - const moveAssetToFolderRef = useRef< - (assetId: string, folderId: string) => void - >(() => {}); authUiRef.current = authUi; const openEditorLoginModal = useCallback( (postLoginAction?: (() => void) | null) => { @@ -268,10 +262,6 @@ export function ImageCanvasEditorView() { onDeleteAssets: removeCanvasLayersLinkedToAssets, }); - useEffect(() => { - assetsRef.current = assets; - }, [assets]); - const handleActivateCanvasGenerationDialog = useCallback(() => { setSelectedLayerId(null); setSelectedLayerIds([]); @@ -723,74 +713,6 @@ export function ImageCanvasEditorView() { }; }, []); - useEffect(() => { - const updatePointerDrag = (event: PointerEvent) => { - const currentDrag = assetPointerDragRef.current; - if (!currentDrag || currentDrag.pointerId !== event.pointerId) { - return; - } - const distance = Math.hypot( - event.clientX - currentDrag.startClientX, - event.clientY - currentDrag.startClientY, - ); - const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY); - const nextDrag: AssetPointerDragState = { - ...currentDrag, - currentClientX: event.clientX, - currentClientY: event.clientY, - active: currentDrag.active || distance > 4, - dropFolderId, - }; - assetPointerDragRef.current = nextDrag; - setAssetPointerDrag(nextDrag); - setUploadDropTarget( - resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null, - ); - updateAssetMoveDropFolder(dropFolderId); - }; - - const finishPointerDrag = (event: PointerEvent) => { - const currentDrag = assetPointerDragRef.current; - if (!currentDrag || currentDrag.pointerId !== event.pointerId) { - return; - } - const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); - const dropFolderId = - resolveAssetFolderId(event.clientX, event.clientY) ?? - currentDrag.dropFolderId; - const draggedAsset = assetsRef.current.find( - (asset) => asset.id === currentDrag.assetId, - ); - assetPointerDragRef.current = null; - setAssetPointerDrag(null); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - if (!currentDrag.active || !draggedAsset) { - return; - } - suppressAssetClickRef.current = true; - window.setTimeout(() => { - suppressAssetClickRef.current = false; - }, 0); - if (dropFolderId && dropFolderId !== draggedAsset.folderId) { - moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); - return; - } - if (canvasPoint) { - addAssetLayerRef.current(draggedAsset, canvasPoint); - } - }; - - window.addEventListener('pointermove', updatePointerDrag); - window.addEventListener('pointerup', finishPointerDrag); - window.addEventListener('pointercancel', finishPointerDrag); - return () => { - window.removeEventListener('pointermove', updatePointerDrag); - window.removeEventListener('pointerup', finishPointerDrag); - window.removeEventListener('pointercancel', finishPointerDrag); - }; - }, []); - const addAssetLayer = ( asset: EditorAsset, position?: { x: number; y: number }, @@ -811,9 +733,19 @@ export function ImageCanvasEditorView() { selectSingleLayer(nextLayer.id); setHoveredLayerId(null); }; - addAssetLayerRef.current = addAssetLayer; - moveAssetToFolderRef.current = moveAssetToFolder; + useImageCanvasAssetPointerDragBridge({ + assetPointerDragRef, + suppressAssetClickRef, + assets, + resolveAssetFolderId, + resolveCanvasPoint, + setAssetPointerDrag, + setUploadDropTarget, + updateAssetMoveDropFolder, + moveAssetToFolder, + addAssetLayer, + }); deleteLayerByIdRef.current = deleteLayerById; diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 73c976c8..3de2400c 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -881,7 +881,7 @@ export function ImageCanvasStageView({ aria-label="画布背景设置" >
- 画布背景色 + 画布背景
+
+