diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 17145ad6e..794c3f518 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -45,6 +45,7 @@ - 处理:让每个固定分区在自己的 `.game-resource-plane` 中拥有独立 SVG,卡片与路径都直接使用布局逻辑坐标并共享父级 CSS scale / 原生 scroll;viewport 原生 overflow 负责本区裁剪。DOM 测量只换算本区逻辑 viewport,用于完整路径、incoming / outgoing 继续线和两端离屏隐藏,不参与端点身份或主路径坐标。每区 observer 和 RAF 各至多一个,卸载时清理。 - 验证:同时挂载至少两个分区和各自同类型关系,断言每条边只存在于对应分区 SVG;只滚动其中一分区,另一分区的逻辑 viewport 与 path 不变。另覆盖缩放后 SVG / 卡片仍在同一 plane、双向离屏继续线、两端离屏隐藏、marker、自环以及 mode / 项目切换清理。 - 关联:`apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx`、`apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。 + ## 正式保存不能等待 React state 才取得草稿 CAS 的新 revision - 现象:用户刚完成编辑就点击“保存到项目”,自动草稿保存已经成功,但正式提交仍携带旧 `expectedDraftRevision`,于是单窗口也得到 draft revision conflict;快速连续保存时还可能使用不同 commitId 重复 staging。 @@ -666,12 +667,12 @@ - 验证:`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`。 -## 图片画布视口拖动卡顿先查自动保存和小地图合帧 +## 图片画布拖动卡顿先查 Stage 合帧和交互期自动保存 -- 现象:素材多或序列帧多时,拖动小地图视口框或手型平移明显卡顿,像是接口慢或 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`。 +- 现象:素材多或序列帧多时,拖动图层、生成占位、小地图视口框或手型平移明显卡顿,像是接口慢或 CSS 动画掉帧,但网络请求不一定异常。 +- 原因:高刷新率输入设备会在单个屏幕帧内发出多次 `pointermove`;每次直接 `setLayers` / `setViewport` 都会触发画布重渲染、吸附或小地图模型重算和工程持久化 effect。即使 `moveLayersFromDrag` 保留未移动图层的对象引用,若 WorldView 仍在每帧重建全部图层子树,所有真实位图的 URL hook、加载态、标签和 SVG 操作也会重复执行。持久化链路还会同步 `serializeCanvasLayout`、`JSON.stringify` 并写 sessionStorage,远端 PATCH 有防抖也挡不住本地同步缓存写入。 +- 处理:Stage 的图层、生成占位、框选和手型平移统一用单一在途 `requestAnimationFrame` 合并同帧输入,只应用最新坐标;结束拖拽时 flush 最后一帧,主动清理和卸载时 cancel。图层、生成占位、平移和小地图拖动一旦越过拖动阈值就标记为临时交互,拖动中不触发项目保存、session cache 写入或封面快照上传,`pointerup` / `pointercancel` 后保存最终布局。WorldView 的完整单图层节点必须按稳定 layer 对象浅比较 memo,父回调通过 latest ref 的稳定门面转发,避免未移动图层重渲染或读取陈旧闭包。小地图继续只在 viewport controls 内合帧,不要重复套 rAF。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx --reporter verbose` 应覆盖只重渲染移动图层、稳定节点调用最新回调、同帧只保留最新坐标、结束前 flush、卸载 cancel、小地图无双重合帧、图层 / 生成占位 / 平移 / 小地图交互边界,以及拖动期间不写 sessionStorage / 不调用 `saveEditorProjectLayout`。浏览器验收必须使用多个独立 raster URL,并区分 rAF 心跳与目标实际位置变化帧;共享 data URI SVG 和包含空闲尾帧的自由 rAF 不能作为拖动流畅证据。 - 关联:`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 716b186f5..7bc040984 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -92,7 +92,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本。生成完成或显式读取返回较新权威快照时,若同项目仍有防抖待保存或在途保存的本地布局,前端必须以新快照的资源和生成终态为权威,只重放本地几何、层序、分组、隐藏、锁定、翻转、viewport、背景色和生成面板编辑,并立即基于新 revision 入队保存;后端新增项必须合入,后端已删除的旧项不得被本地旧快照复活,本地在请求期间删除的旧项也不得复活。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。图层、生成占位、手型平移和小地图拖动都属于临时画布交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终布局或 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本。生成完成或显式读取返回较新权威快照时,若同项目仍有防抖待保存或在途保存的本地布局,前端必须以新快照的资源和生成终态为权威,只重放本地几何、层序、分组、隐藏、锁定、翻转、viewport、背景色和生成面板编辑,并立即基于新 revision 入队保存;后端新增项必须合入,后端已删除的旧项不得被本地旧快照复活,本地在请求期间删除的旧项也不得复活。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -219,7 +219,7 @@ - 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。触摸素材卡仍可切换选中态,但触摸列表空白区必须继续滚动,不能捕获为框选;鼠标 / 笔框选坐标必须包含列表滚动偏移,`pointerup` 提交最后坐标,`pointercancel` 只取消。内置、上传未完成、上传失败或无可读来源的素材行不得暴露可用的选择按钮。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。 - 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认目标使用打开弹窗时的素材 ID 快照;选择集合、可见集合或选择模式漂移后关闭旧确认弹窗,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,同时受当前视口高度上限约束,不得在横屏或嵌入式短视口中挤占画布。选择模式隐藏单素材下载 / 重命名动作,进入时清理已有拖拽 / 投放状态,并同时阻断素材行、文件夹空白区的内部素材移动和外部文件投放以及右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。 - 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 -- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 +- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。Stage 中图层、生成占位、框选和手型平移的高频 `pointermove` 必须通过单一在途 `requestAnimationFrame` 合帧,同一帧只保留最新坐标;`pointerup` / `pointercancel` 必须 flush 最后坐标,主动清理或卸载必须 cancel,避免迟到回调。图层移动结果必须保留未移动 layer 的对象引用,WorldView 的完整单图层子树按该引用和选中、悬浮、生成、菜单等标量状态浅比较 memo;父级事件回调通过读取最新实现的稳定门面转发,不能因 callback identity 变化击穿全部图层 memo,也不能用自定义比较忽略回调后留下陈旧闭包。小地图保留 viewport controls 内部的单层合帧,不得在 Stage 再套一层 rAF 增加一帧延迟。性能验收使用多个独立 raster 图片 URL、外部可信 pointer 输入和实际位置变化帧,单独运行的 rAF 心跳、重复 data URI SVG 或拖动结束后的空闲帧不得计作拖动 FPS。 - 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 diff --git a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts index 58263c441..d5b3411cc 100644 --- a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts +++ b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts @@ -39,6 +39,8 @@ import { type RefObject, type SetStateAction, useCallback, + useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -126,6 +128,65 @@ type PendingDragHistory = { committed: boolean; }; +type PendingPointerMove = { + pointerId: number; + pointer: { x: number; y: number }; +}; + +function mergeDraggedLayerPositions({ + currentLayers, + movedLayers, + movedLayerIds, +}: { + currentLayers: CanvasLayer[]; + movedLayers: CanvasLayer[]; + movedLayerIds: readonly string[]; +}) { + const movedLayerIdSet = new Set(movedLayerIds); + const movedLayerPositions = new Map( + movedLayers + .filter((layer) => movedLayerIdSet.has(layer.id)) + .map((layer) => [layer.id, { x: layer.x, y: layer.y }] as const), + ); + return currentLayers.map((currentLayer) => { + const movedPosition = movedLayerPositions.get(currentLayer.id); + if ( + !movedPosition || + (currentLayer.x === movedPosition.x && currentLayer.y === movedPosition.y) + ) { + return currentLayer; + } + return { + ...currentLayer, + x: movedPosition.x, + y: movedPosition.y, + }; + }); +} + +function mergeDraggedGenerationPlaceholder( + currentDialog: CanvasGenerationDialogState, + movedDialog: CanvasGenerationDialogState, +) { + if (!currentDialog.placeholder || !movedDialog.placeholder) { + return currentDialog; + } + if ( + currentDialog.placeholder.x === movedDialog.placeholder.x && + currentDialog.placeholder.y === movedDialog.placeholder.y + ) { + return currentDialog; + } + return { + ...currentDialog, + placeholder: { + ...currentDialog.placeholder, + x: movedDialog.placeholder.x, + y: movedDialog.placeholder.y, + }, + }; +} + function focusCanvasInteractionTarget(target: HTMLElement) { target.focus({ preventScroll: true }); } @@ -189,6 +250,11 @@ export function useImageCanvasStageInteractions({ const dragStateRef = useRef(null); const pendingDragHistoryRef = useRef(null); const pendingClickCollapseRef = useRef(null); + const pendingPointerMoveRef = useRef(null); + const pointerMoveFrameRef = useRef(null); + const latestApplyPointerMoveRef = useRef< + (pointerMove: PendingPointerMove) => void + >(() => {}); const isShiftPressedRef = useRef(false); const suppressNextLayerClickRef = useRef(false); const isViewportInteractionActiveRef = useRef(false); @@ -217,6 +283,11 @@ export function useImageCanvasStageInteractions({ }, [onViewportInteractionStart]); const clearActiveInteraction = useCallback(() => { + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + pendingPointerMoveRef.current = null; const dragState = dragStateRef.current; if (dragState?.kind === 'minimap') { flushMinimapViewportDrag(); @@ -227,9 +298,7 @@ export function useImageCanvasStageInteractions({ setCanvasMarquee(null); setIsPanning(false); setSnapGuide(null); - if (dragState?.kind === 'pan' || dragState?.kind === 'minimap') { - finishViewportInteraction(); - } + finishViewportInteraction(); }, [finishViewportInteraction, flushMinimapViewportDrag]); const rejectRightButtonInteraction = useCallback(() => { @@ -622,16 +691,12 @@ export function useImageCanvasStageInteractions({ [canvasViewportRef, minimapScale, startViewportInteraction, viewport], ); - const handlePointerMove = useCallback( - (event: ReactPointerEvent) => { - if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) { - return; - } - if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { - event.preventDefault(); + const applyPointerMove = useCallback( + ({ pointerId, pointer }: PendingPointerMove) => { + if (canvasMarquee && canvasMarquee.pointerId === pointerId) { const rect = canvasViewportRef.current?.getBoundingClientRect(); const currentPoint = getCanvasPointFromPointer({ - pointer: getPointerClient(event), + pointer, rect, }); setCanvasMarquee((currentMarquee) => @@ -660,7 +725,6 @@ export function useImageCanvasStageInteractions({ } const dragState = dragStateRef.current; - const pointerId = getPointerId(event); if ( !dragState || (dragState.pointerId >= 0 && @@ -670,12 +734,11 @@ export function useImageCanvasStageInteractions({ return; } - const dragPointer = getPointerClient(event); const pendingDragHistory = pendingDragHistoryRef.current; if (pendingDragHistory && !pendingDragHistory.committed) { const movementDistance = Math.hypot( - dragPointer.x - pendingDragHistory.startClientX, - dragPointer.y - pendingDragHistory.startClientY, + pointer.x - pendingDragHistory.startClientX, + pointer.y - pendingDragHistory.startClientY, ); if (movementDistance < CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX) { return; @@ -684,16 +747,15 @@ export function useImageCanvasStageInteractions({ snapshot: pendingDragHistory.snapshot, }); pendingDragHistory.committed = true; + startViewportInteraction(); } if (dragState.kind === 'pan') { - const pointer = getPointerClient(event); setViewport(moveViewportFromPan(dragState, pointer)); return; } if (dragState.kind === 'generation-frame') { - const pointer = getPointerClient(event); const movedFrameResult = moveGenerationFramesFromDrag({ dragState, generationDialogs: canvasGenerationDialogs, @@ -705,21 +767,27 @@ export function useImageCanvasStageInteractions({ if (!dragState.dialogIds.includes(nextDialog.id)) { return; } - updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); + updateCanvasGenerationDialogById(nextDialog.id, (currentDialog) => + mergeDraggedGenerationPlaceholder(currentDialog, nextDialog), + ); }); - setLayers((currentLayers) => - moveDragSelectedLayers({ + setLayers((currentLayers) => { + const movedLayers = moveDragSelectedLayers({ dragState, layers: currentLayers, snapOffset: movedFrameResult.snapOffset, pointer, - }), - ); + }); + return mergeDraggedLayerPositions({ + currentLayers, + movedLayers, + movedLayerIds: dragState.layerIds, + }); + }); return; } if (dragState.kind === 'minimap') { - const pointer = getPointerClient(event); const nextDragState = updateMinimapDragMovement(dragState, pointer); if (nextDragState !== dragState) { dragStateRef.current = nextDragState; @@ -730,7 +798,6 @@ export function useImageCanvasStageInteractions({ return; } - const pointer = getPointerClient(event); const movedLayers = moveLayersFromDrag({ dragState, layers, @@ -741,7 +808,13 @@ export function useImageCanvasStageInteractions({ return; } setSnapGuide(movedLayers.snapGuide); - setLayers(movedLayers.layers); + setLayers((currentLayers) => + mergeDraggedLayerPositions({ + currentLayers, + movedLayers: movedLayers.layers, + movedLayerIds: dragState.layerIds, + }), + ); const movedFrameResult = moveGenerationFramesFromDrag({ dragState, generationDialogs: canvasGenerationDialogs, @@ -753,7 +826,9 @@ export function useImageCanvasStageInteractions({ if (!dragState.generationDialogIds?.includes(nextDialog.id)) { return; } - updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); + updateCanvasGenerationDialogById(nextDialog.id, (currentDialog) => + mergeDraggedGenerationPlaceholder(currentDialog, nextDialog), + ); }); }, [ @@ -766,15 +841,103 @@ export function useImageCanvasStageInteractions({ setSelectedLayerId, setSelectedLayerIds, setViewport, + startViewportInteraction, updateCanvasGenerationDialogById, updateViewportFromMinimapDrag, viewport, ], ); + useLayoutEffect(() => { + latestApplyPointerMoveRef.current = applyPointerMove; + }, [applyPointerMove]); + + const flushPendingPointerMove = useCallback(() => { + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + const pendingPointerMove = pendingPointerMoveRef.current; + pendingPointerMoveRef.current = null; + if (pendingPointerMove) { + latestApplyPointerMoveRef.current(pendingPointerMove); + } + }, []); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) { + return; + } + if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { + event.preventDefault(); + } + const pointerMove = { + pointerId: getPointerId(event), + pointer: getPointerClient(event), + }; + const dragState = dragStateRef.current; + if (!canvasMarquee && !dragState) { + return; + } + // 小地图内部已经按动画帧合并 viewport 更新;这里仅同步更新其 + // moved 状态,避免再套一层 rAF 带来额外一帧延迟。 + if (dragState?.kind === 'minimap') { + latestApplyPointerMoveRef.current(pointerMove); + return; + } + if (pointerMoveFrameRef.current === null) { + latestApplyPointerMoveRef.current(pointerMove); + const drainPendingPointerMove = () => { + const pendingPointerMove = pendingPointerMoveRef.current; + pendingPointerMoveRef.current = null; + if (!pendingPointerMove) { + pointerMoveFrameRef.current = null; + return; + } + latestApplyPointerMoveRef.current(pendingPointerMove); + pointerMoveFrameRef.current = window.requestAnimationFrame( + drainPendingPointerMove, + ); + }; + pointerMoveFrameRef.current = window.requestAnimationFrame( + drainPendingPointerMove, + ); + return; + } + pendingPointerMoveRef.current = pointerMove; + }, + [canvasMarquee], + ); + + useEffect( + () => () => { + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + } + pointerMoveFrameRef.current = null; + pendingPointerMoveRef.current = null; + }, + [], + ); + const finishDrag = useCallback( (event: ReactPointerEvent) => { - if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { + const dragState = dragStateRef.current; + const pointerId = getPointerId(event); + const isMatchingMarquee = Boolean( + canvasMarquee && canvasMarquee.pointerId === event.pointerId, + ); + const isMatchingDrag = + dragState && + (dragState.pointerId < 0 || + pointerId < 0 || + dragState.pointerId === pointerId); + if (!isMatchingMarquee && !isMatchingDrag) { + return; + } + flushPendingPointerMove(); + if (isMatchingMarquee) { event.preventDefault(); setCanvasMarquee(null); if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { @@ -783,14 +946,7 @@ export function useImageCanvasStageInteractions({ return; } - const dragState = dragStateRef.current; - const pointerId = getPointerId(event); - if ( - dragState && - (dragState.pointerId < 0 || - pointerId < 0 || - dragState.pointerId === pointerId) - ) { + if (dragState && isMatchingDrag) { if (dragState.kind === 'minimap' && !dragState.moved) { const pointer = getPointerClient(event); moveViewportFromMinimapPointer(pointer.x, pointer.y); @@ -822,9 +978,7 @@ export function useImageCanvasStageInteractions({ pendingDragHistoryRef.current = null; setIsPanning(false); setSnapGuide(null); - if (dragState.kind === 'pan' || dragState.kind === 'minimap') { - finishViewportInteraction(); - } + finishViewportInteraction(); if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { canvasViewportRef.current.releasePointerCapture?.(event.pointerId); } @@ -835,6 +989,7 @@ export function useImageCanvasStageInteractions({ canvasViewportRef, finishViewportInteraction, flushMinimapViewportDrag, + flushPendingPointerMove, moveViewportFromMinimapPointer, setSelectedLayerId, setSelectedLayerIds, diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 41f0f7341..cc98a733e 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -287,6 +287,69 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('角色')).toBeTruthy(); }); + it('only rerenders the moved image layer when other layer objects stay stable', () => { + const firstLayer = createLayer({ + id: 'layer-first', + resourceId: 'resource-first', + title: '第一张图片', + zIndex: 1, + }); + const movedLayer = createLayer({ + id: 'layer-moved', + resourceId: 'resource-moved', + title: '移动图片', + zIndex: 2, + }); + const lastLayer = createLayer({ + id: 'layer-last', + resourceId: 'resource-last', + title: '最后一张图片', + zIndex: 3, + }); + const { props, rerender } = renderWorldView({ + layers: [firstLayer, movedLayer, lastLayer], + }); + const initialReadUrlCallCount = + useResolvedAssetReadUrlMock.mock.calls.length; + + rerender( + , + ); + + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledTimes( + initialReadUrlCallCount + 1, + ); + }); + + it('calls the latest layer callback without rerendering a stable layer', () => { + const layer = createLayer(); + const firstOnLayerClick = vi.fn(); + const latestOnLayerClick = vi.fn(); + const { props, rerender } = renderWorldView({ + layers: [layer], + onLayerClick: firstOnLayerClick, + }); + + rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: '选择角色主图' })); + + expect(latestOnLayerClick).toHaveBeenCalledWith(expect.any(Object), layer); + expect(firstOnLayerClick).not.toHaveBeenCalled(); + }); + it('shows editable kind tags for tagged and untagged layers', () => { const layer = createLayer({ assetKind: null }); renderWorldView({ diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 065ce5da5..42315907a 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -26,8 +26,18 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, + RefObject, + SetStateAction, +} from 'react'; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { @@ -305,6 +315,20 @@ export type ImageCanvasWorldViewProps = { ) => void; }; +type ImageCanvasLayerHandlers = Pick< + ImageCanvasWorldViewProps, + | 'onLayerPointerDown' + | 'onLayerClick' + | 'onSelectLayer' + | 'onLayerContextMenu' + | 'onLayerMouseEnter' + | 'onLayerMouseLeave' + | 'onOpenLayerMetadata' + | 'onUpdateLayerAssetKind' +> & { + setOpenLayerKindMenuId: (value: SetStateAction) => void; +}; + const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); const CROP_EXPAND_HANDLES: Array<{ @@ -912,6 +936,186 @@ function ImageCanvasImageSequenceLayer({ ); } +const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ + layer, + isSelected, + isHovered, + layerGeneratingLabel, + isKindMenuOpen, + inverseScaleStyle, + handlers, + openLayerKindTriggerRef, + openLayerKindMenuRef, +}: { + layer: CanvasLayer; + isSelected: boolean; + isHovered: boolean; + layerGeneratingLabel: string | null; + isKindMenuOpen: boolean; + inverseScaleStyle: CSSProperties & Record; + handlers: ImageCanvasLayerHandlers; + openLayerKindTriggerRef: RefObject; + openLayerKindMenuRef: RefObject; +}) { + const kindTagMeta = getLayerKindTagMeta(layer); + const kindLabel = kindTagMeta.label; + const mediaTransform = + layer.flipX || layer.flipY + ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` + : undefined; + const isMediaContainer = isCanvasMediaContainerLayer(layer); + const metadataMediaLabel = getLayerMetadataMediaLabel(layer); + + return ( +
+ handlers.onLayerPointerDown(event, layer)} + onClick={(event) => { + handlers.setOpenLayerKindMenuId(null); + handlers.onLayerClick(event, layer); + }} + onContextMenu={(event) => { + handlers.setOpenLayerKindMenuId(null); + handlers.onLayerContextMenu(event, layer); + }} + onKeyDown={isMediaContainer ? handleLayerKeyboardActivation : undefined} + onMouseEnter={() => handlers.onLayerMouseEnter(layer.id)} + onMouseLeave={() => handlers.onLayerMouseLeave(layer.id)} + aria-label={`选择${layer.title}`} + > + {layer.mediaType === 'audio' ? ( + handlers.onSelectLayer(layer.id)} + /> + ) : layer.mediaType === 'video' ? ( + + ) : layer.mediaType === 'image-sequence' ? ( + + ) : ( + + )} + {kindLabel ? ( + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ); + }} + onKeyDown={handleLayerTagKeyboardActivation} + > + {kindLabel} + + ) : null} + } + style={inverseScaleStyle} + onClick={(event) => { + event.stopPropagation(); + handlers.onOpenLayerMetadata(layer); + }} + onPointerDown={(event) => event.stopPropagation()} + /> + {isHovered && layer.mediaType !== 'audio' ? ( + + {getLayerMetricLabel(layer)} + + ) : null} + {layerGeneratingLabel ? ( + + {layerGeneratingLabel} + + ) : null} + + {isKindMenuOpen ? ( +
+ event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => { + const compatible = isCanvasAssetKindOverrideCompatible( + resolveLayerResourceAssetKind(layer), + option.value, + ); + return ( + { + event.stopPropagation(); + handlers.onUpdateLayerAssetKind(layer.id, option.value); + handlers.setOpenLayerKindMenuId(null); + }} + > + {option.label} + + ); + })} + +
+ ) : null} +
+ ); +}); + export function ImageCanvasWorldView({ viewport, snapGuide, @@ -939,12 +1143,78 @@ export function ImageCanvasWorldView({ onCropExpandHandlePointerDown, }: ImageCanvasWorldViewProps) { const inverseScale = getInverseViewportScale(viewport); - const inverseScaleStyle = buildInverseScaleStyle(inverseScale); + const inverseScaleStyle = useMemo( + () => buildInverseScaleStyle(inverseScale), + [inverseScale], + ); + const selectedLayerIdSet = useMemo( + () => new Set(selectedLayerIds), + [selectedLayerIds], + ); const [openLayerKindMenuId, setOpenLayerKindMenuId] = useState( null, ); const openLayerKindTriggerRef = useRef(null); const openLayerKindMenuRef = useRef(null); + const latestLayerHandlersRef = useRef({ + onLayerPointerDown, + onLayerClick, + onSelectLayer, + onLayerContextMenu, + onLayerMouseEnter, + onLayerMouseLeave, + onOpenLayerMetadata, + onUpdateLayerAssetKind, + setOpenLayerKindMenuId, + }); + useLayoutEffect(() => { + latestLayerHandlersRef.current = { + onLayerPointerDown, + onLayerClick, + onSelectLayer, + onLayerContextMenu, + onLayerMouseEnter, + onLayerMouseLeave, + onOpenLayerMetadata, + onUpdateLayerAssetKind, + setOpenLayerKindMenuId, + }; + }, [ + onLayerPointerDown, + onLayerClick, + onSelectLayer, + onLayerContextMenu, + onLayerMouseEnter, + onLayerMouseLeave, + onOpenLayerMetadata, + onUpdateLayerAssetKind, + ]); + const layerHandlers = useMemo( + () => ({ + onLayerPointerDown: (event, layer) => + latestLayerHandlersRef.current.onLayerPointerDown(event, layer), + onLayerClick: (event, layer) => + latestLayerHandlersRef.current.onLayerClick(event, layer), + onSelectLayer: (layerId) => + latestLayerHandlersRef.current.onSelectLayer(layerId), + onLayerContextMenu: (event, layer) => + latestLayerHandlersRef.current.onLayerContextMenu(event, layer), + onLayerMouseEnter: (layerId) => + latestLayerHandlersRef.current.onLayerMouseEnter(layerId), + onLayerMouseLeave: (layerId) => + latestLayerHandlersRef.current.onLayerMouseLeave(layerId), + onOpenLayerMetadata: (layer) => + latestLayerHandlersRef.current.onOpenLayerMetadata(layer), + onUpdateLayerAssetKind: (layerId, assetKind) => + latestLayerHandlersRef.current.onUpdateLayerAssetKind( + layerId, + assetKind, + ), + setOpenLayerKindMenuId: (value) => + latestLayerHandlersRef.current.setOpenLayerKindMenuId(value), + }), + [], + ); const visibleLayers = useMemo( () => layers @@ -1005,14 +1275,8 @@ export function ImageCanvasWorldView({ ) : null} {visibleLayers.map((layer) => { - const isSelected = selectedLayerIds.includes(layer.id); + const isSelected = selectedLayerIdSet.has(layer.id); const isHovered = hoveredLayerId === layer.id; - const kindTagMeta = getLayerKindTagMeta(layer); - const kindLabel = kindTagMeta.label; - const mediaTransform = - layer.flipX || layer.flipY - ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` - : undefined; const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( layer.id, ) @@ -1022,163 +1286,19 @@ export function ImageCanvasWorldView({ generateDialog.sourceLayerId === layer.id ? '修改中' : null; - const isMediaContainer = isCanvasMediaContainerLayer(layer); - const metadataMediaLabel = getLayerMetadataMediaLabel(layer); return ( -
- onLayerPointerDown(event, layer)} - onClick={(event) => { - setOpenLayerKindMenuId(null); - onLayerClick(event, layer); - }} - onContextMenu={(event) => { - setOpenLayerKindMenuId(null); - onLayerContextMenu(event, layer); - }} - onKeyDown={ - isMediaContainer ? handleLayerKeyboardActivation : undefined - } - onMouseEnter={() => onLayerMouseEnter(layer.id)} - onMouseLeave={() => onLayerMouseLeave(layer.id)} - aria-label={`选择${layer.title}`} - > - {layer.mediaType === 'audio' ? ( - onSelectLayer(layer.id)} - /> - ) : layer.mediaType === 'video' ? ( - - ) : layer.mediaType === 'image-sequence' ? ( - - ) : ( - - )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } - style={inverseScaleStyle} - onClick={(event) => { - event.stopPropagation(); - onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} - /> - {isHovered && layer.mediaType !== 'audio' ? ( - - {getLayerMetricLabel(layer)} - - ) : null} - {layerGeneratingLabel ? ( - - {layerGeneratingLabel} - - ) : null} - - {openLayerKindMenuId === layer.id ? ( -
- event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => { - const compatible = isCanvasAssetKindOverrideCompatible( - resolveLayerResourceAssetKind(layer), - option.value, - ); - return ( - { - event.stopPropagation(); - onUpdateLayerAssetKind(layer.id, option.value); - setOpenLayerKindMenuId(null); - }} - > - {option.label} - - ); - })} - -
- ) : null} -
+ ); })} { const isFocused = generateDialog?.id === dialog.id; - const isSelected = selectedLayerIds.includes( + const isSelected = selectedLayerIdSet.has( getCanvasGenerationSelectionId(dialog.id), ); const showFocusedChrome = diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 2c0407dfe..d50e63623 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -110,6 +110,7 @@ function asCanvasGenerationDialog( } function StageInteractionsHarness({ + includeConcurrentLayerFixture = false, pickCharacterSpecFromLayer = vi.fn(), pickGenerationReferenceFromLayer = vi.fn(), pickIconSpecFromLayer = vi.fn(), @@ -122,6 +123,7 @@ function StageInteractionsHarness({ onViewportInteractionEnd = vi.fn(), captureCanvasHistory = vi.fn(), }: { + includeConcurrentLayerFixture?: boolean; pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void; pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void; pickIconSpecFromLayer?: (layer: CanvasLayer) => void; @@ -150,9 +152,12 @@ function StageInteractionsHarness({ const canvasViewportRef = useRef(null); const worldRef = useRef(null); const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select'); - const [layers, setLayers] = useState([ + const [layers, setLayers] = useState(() => [ createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }), createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }), + ...(includeConcurrentLayerFixture + ? [createLayer({ id: 'removed', x: 900, y: 900, zIndex: 3 })] + : []), ]); const [viewport, setViewport] = useState({ x: 0, @@ -331,6 +336,14 @@ function StageInteractionsHarness({ ) .join('|')} + + {layers + .map( + (layer) => + `${layer.id}:${layer.title}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`, + ) + .join('|')} + {viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale} @@ -351,6 +364,17 @@ function StageInteractionsHarness({ ? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}` : '-'} + + {generateDialog + ? [ + generateDialog.status, + generateDialog.prompt, + generateDialog.errorMessage ?? '-', + generateDialog.placeholder?.width ?? '-', + generateDialog.placeholder?.height ?? '-', + ].join(':') + : '-'} + {getCanvasGenerationSelectionId('dialog-1')} @@ -440,6 +464,48 @@ function StageInteractionsHarness({ > 直接移动图层 + + + + +