diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bb95eae6d..b621929fb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -135,6 +135,14 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片画布视口拖动卡顿先查自动保存和小地图合帧 + +- 现象:素材多或序列帧多时,拖动小地图视口框或手型平移明显卡顿,像是接口慢或 CSS 动画掉帧,但网络请求不一定异常。 +- 原因:`pointermove` 高频修改 `viewport` 会触发画布重渲染、小地图模型重算和工程持久化 effect;持久化链路会同步 `serializeCanvasLayout`、`JSON.stringify` 并写 sessionStorage。远端 PATCH 有防抖也挡不住本地同步缓存写入。 +- 处理:把 viewport 拖动标记为临时交互;拖动中只更新画布显示,不触发项目保存、session cache 写入或封面快照上传,`pointerup` / `pointercancel` 后保存最终 viewport。小地图拖动的 `updateViewportFromMinimapDrag` 必须用 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx --reporter verbose` 应覆盖小地图拖动合帧、平移 / 小地图 viewport 交互边界,以及拖动期间不写 sessionStorage / 不调用 `saveEditorProjectLayout`。 +- 关联:`src/components/image-editor/useImageCanvasViewportControls.ts`、`src/components/image-editor/useImageCanvasStageInteractions.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器宣发素材生成器刷新后不要丢快照 - 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index b81957a4d..b4f865dfc 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -35,7 +35,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -116,7 +116,7 @@ - `生成角色动作` 的完成结果同样按视频素材处理:图层主 `src` 使用后端返回的 `previewVideoPath`,`mediaType` 固定为 `video`,`assetKind` 固定为 `character-animation`,首个透明帧只写入 `thumbnailSrc` 供 poster 和后续动作源使用。下载和 ZIP 导出必须因此得到 mp4/webm/mov 等真实视频文件,不能回退为首帧 PNG;后端抽帧、绿幕去背和帧素材落盘流程仍完整保留。 - 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 - 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 -- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。 +- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 - 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 1d8660559..ce0961810 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -154,6 +154,7 @@ export function ImageCanvasEditorView() { const [contextMenu, setContextMenu] = useState( null, ); + const [isViewportInteracting, setIsViewportInteracting] = useState(false); const [uploadDropTarget, setUploadDropTarget] = useState< 'canvas' | 'assets' | null >(null); @@ -173,11 +174,18 @@ export function ImageCanvasEditorView() { getCanvasPointFromClient, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, + flushMinimapViewportDrag, } = useImageCanvasViewportControls({ canvasViewportRef, layers, captureCanvasHistory: captureViewportHistory, }); + const beginViewportInteraction = useCallback(() => { + setIsViewportInteracting(true); + }, []); + const endViewportInteraction = useCallback(() => { + setIsViewportInteracting(false); + }, []); selectedLayerIdRef.current = selectedLayerId; selectedLayerIdsRef.current = selectedLayerIds; @@ -669,6 +677,7 @@ export function ImageCanvasEditorView() { layers, canvasGenerationDialogs, viewport, + isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, }); @@ -979,7 +988,10 @@ export function ImageCanvasEditorView() { updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, + flushMinimapViewportDrag, minimapScale: minimapModel?.scale ?? 1, + onViewportInteractionStart: beginViewportInteraction, + onViewportInteractionEnd: endViewportInteraction, onCloseImageContextMenu: () => setImageContextMenu(null), }); resetCanvasInteractionStateRef.current = clearActiveInteraction; diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 60d6f9b0c..ea78798fd 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -102,6 +102,7 @@ function ProjectPersistenceHarness({ y: 0, scale: 1, }); + const [isViewportInteracting, setIsViewportInteracting] = useState(false); const [projectTitle, setProjectTitle] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); @@ -147,6 +148,7 @@ function ProjectPersistenceHarness({ layers, canvasGenerationDialogs: generationDialogs, viewport, + isViewportInteracting, canAccessProtectedData, openEditorLoginModal: openEditorLoginModalRef.current, }); @@ -171,6 +173,9 @@ function ProjectPersistenceHarness({ {viewport.x},{viewport.y},{viewport.scale} + + {String(isViewportInteracting)} + {generationDialogs .map( @@ -179,6 +184,34 @@ function ProjectPersistenceHarness({ ) .join(',')} + + + + ); } @@ -685,9 +708,12 @@ describe('useImageCanvasStageInteractions', () => { screen.getByRole('button', { name: '直接移动平移' }).click(); }); expect(screen.getByTestId('viewport-state').textContent).toBe('30.0,25.0,1'); - act(() => { screen.getByRole('button', { name: '清理交互' }).click(); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + + act(() => { screen.getByRole('button', { name: '松开空格' }).click(); }); act(() => { @@ -763,4 +789,40 @@ describe('useImageCanvasStageInteractions', () => { 95, ); }); + + it('marks viewport interactions for pan and minimap drags', () => { + const onViewportInteractionStart = vi.fn(); + const onViewportInteractionEnd = vi.fn(); + const flushMinimapViewportDrag = vi.fn(); + render( + , + ); + + act(() => { + screen.getByRole('button', { name: '切抓手' }).click(); + }); + act(() => { + screen.getByRole('button', { name: '直接开始平移' }).click(); + }); + expect(onViewportInteractionStart).toHaveBeenCalledTimes(1); + act(() => { + screen.getByRole('button', { name: '清理交互' }).click(); + }); + expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1); + + act(() => { + screen.getByRole('button', { name: '直接拖小地图' }).click(); + }); + expect(onViewportInteractionStart).toHaveBeenCalledTimes(2); + act(() => { + screen.getByRole('button', { name: '直接结束小地图拖拽' }).click(); + }); + + expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1); + expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 2d9ead0c1..26a6366f7 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -85,7 +85,10 @@ type UseImageCanvasStageInteractionsOptions = { clientX: number, clientY: number, ) => void; + flushMinimapViewportDrag: () => void; minimapScale: number; + onViewportInteractionStart?: () => void; + onViewportInteractionEnd?: () => void; onCloseImageContextMenu: () => void; }; @@ -121,12 +124,16 @@ export function useImageCanvasStageInteractions({ updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, + flushMinimapViewportDrag, minimapScale, + onViewportInteractionStart, + onViewportInteractionEnd, onCloseImageContextMenu, }: UseImageCanvasStageInteractionsOptions) { const dragStateRef = useRef(null); const isShiftPressedRef = useRef(false); const suppressNextLayerClickRef = useRef(false); + const isViewportInteractionActiveRef = useRef(false); const [canvasMarquee, setCanvasMarquee] = useState( null, ); @@ -135,12 +142,35 @@ export function useImageCanvasStageInteractions({ const [snapGuide, setSnapGuide] = useState(null); const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; + const finishViewportInteraction = useCallback(() => { + if (!isViewportInteractionActiveRef.current) { + return; + } + isViewportInteractionActiveRef.current = false; + onViewportInteractionEnd?.(); + }, [onViewportInteractionEnd]); + + const startViewportInteraction = useCallback(() => { + if (isViewportInteractionActiveRef.current) { + return; + } + isViewportInteractionActiveRef.current = true; + onViewportInteractionStart?.(); + }, [onViewportInteractionStart]); + const clearActiveInteraction = useCallback(() => { + const dragState = dragStateRef.current; + if (dragState?.kind === 'minimap') { + flushMinimapViewportDrag(); + } dragStateRef.current = null; setCanvasMarquee(null); setIsPanning(false); setSnapGuide(null); - }, []); + if (dragState?.kind === 'pan' || dragState?.kind === 'minimap') { + finishViewportInteraction(); + } + }, [finishViewportInteraction, flushMinimapViewportDrag]); const setShiftPressed = useCallback((pressed: boolean) => { isShiftPressedRef.current = pressed; @@ -152,13 +182,14 @@ export function useImageCanvasStageInteractions({ const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); setIsPanning(true); + startViewportInteraction(); dragStateRef.current = createPanDragState({ pointerId: getPointerId(event), pointer, viewport, }); }, - [canvasViewportRef, viewport], + [canvasViewportRef, startViewportInteraction, viewport], ); const handleCanvasPointerDown = useCallback( @@ -437,6 +468,7 @@ export function useImageCanvasStageInteractions({ event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); + startViewportInteraction(); dragStateRef.current = createMinimapDragState({ pointerId: getPointerId(event), pointer, @@ -444,7 +476,7 @@ export function useImageCanvasStageInteractions({ minimapScale, }); }, - [canvasViewportRef, minimapScale, viewport], + [canvasViewportRef, minimapScale, startViewportInteraction, viewport], ); const handlePointerMove = useCallback( @@ -600,15 +632,27 @@ export function useImageCanvasStageInteractions({ const pointer = getPointerClient(event); moveViewportFromMinimapPointer(pointer.x, pointer.y); } + if (dragState.kind === 'minimap') { + flushMinimapViewportDrag(); + } dragStateRef.current = null; setIsPanning(false); setSnapGuide(null); + if (dragState.kind === 'pan' || dragState.kind === 'minimap') { + finishViewportInteraction(); + } if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { canvasViewportRef.current.releasePointerCapture?.(event.pointerId); } } }, - [canvasMarquee, canvasViewportRef, moveViewportFromMinimapPointer], + [ + canvasMarquee, + canvasViewportRef, + finishViewportInteraction, + flushMinimapViewportDrag, + moveViewportFromMinimapPointer, + ], ); return useMemo( diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index a4fcc240d..90f04fb43 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { useRef } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; @@ -135,6 +135,30 @@ function ViewportHarness({ > minimap drag + +