优化画布视口拖动性能
小地图拖动通过 requestAnimationFrame 合帧并在结束时刷新最后一帧 平移和小地图拖动期间跳过项目自动保存与 session 缓存写入 补充视口交互性能回归测试和项目文档记录
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 图片编辑器宣发素材生成器刷新后不要丢快照
|
||||
|
||||
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
|
||||
|
||||
@@ -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=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
@@ -154,6 +154,7 @@ export function ImageCanvasEditorView() {
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
<span data-testid="viewport">
|
||||
{viewport.x},{viewport.y},{viewport.scale}
|
||||
</span>
|
||||
<span data-testid="viewport-interacting">
|
||||
{String(isViewportInteracting)}
|
||||
</span>
|
||||
<span data-testid="generation-dialogs">
|
||||
{generationDialogs
|
||||
.map(
|
||||
@@ -179,6 +184,34 @@ function ProjectPersistenceHarness({
|
||||
)
|
||||
.join(',')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewport((currentViewport) => ({
|
||||
...currentViewport,
|
||||
x: currentViewport.x + 10,
|
||||
y: currentViewport.y + 5,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
move viewport
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsViewportInteracting(true);
|
||||
}}
|
||||
>
|
||||
begin viewport interaction
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsViewportInteracting(false);
|
||||
}}
|
||||
>
|
||||
end viewport interaction
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -651,6 +684,54 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not serialize or write session cache while viewport interactions are active', async () => {
|
||||
const sessionSetItemSpy = vi.spyOn(
|
||||
Storage.prototype,
|
||||
'setItem',
|
||||
);
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
sessionSetItemSpy.mockClear();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'begin viewport interaction' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport-interacting').textContent).toBe('true');
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'move viewport' }).click();
|
||||
screen.getByRole('button', { name: 'move viewport' }).click();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
expect(sessionSetItemSpy).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'end viewport interaction' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('viewport-interacting').textContent).toBe(
|
||||
'false',
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
}),
|
||||
);
|
||||
expect(sessionSetItemSpy).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
sessionSetItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not create a project resource again for backend-persisted generated layers', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
|
||||
@@ -280,6 +280,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
openEditorLoginModal,
|
||||
}: {
|
||||
@@ -288,6 +289,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
viewport: CanvasViewport;
|
||||
isViewportInteracting: boolean;
|
||||
canAccessProtectedData: boolean;
|
||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
}) {
|
||||
@@ -711,17 +713,31 @@ export function useImageCanvasProjectPersistence({
|
||||
openEditorLoginModal,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !isProjectReady) {
|
||||
return undefined;
|
||||
}
|
||||
if (skipNextProjectLayoutSaveRef.current) {
|
||||
skipNextProjectLayoutSaveRef.current = false;
|
||||
persistProjectCoverSnapshot(
|
||||
projectId,
|
||||
viewportToCanvasDisplayViewport(viewport),
|
||||
layers,
|
||||
);
|
||||
if (!isViewportInteracting) {
|
||||
persistProjectCoverSnapshot(
|
||||
projectId,
|
||||
viewportToCanvasDisplayViewport(viewport),
|
||||
layers,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (isViewportInteracting) {
|
||||
return undefined;
|
||||
}
|
||||
queueProjectLayoutSave(
|
||||
@@ -735,16 +751,11 @@ export function useImageCanvasProjectPersistence({
|
||||
},
|
||||
{ delayMs: 450 },
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
return undefined;
|
||||
}, [
|
||||
isProjectReady,
|
||||
canvasGenerationDialogs,
|
||||
isViewportInteracting,
|
||||
layers,
|
||||
persistProjectCoverSnapshot,
|
||||
projectId,
|
||||
|
||||
@@ -115,6 +115,9 @@ function StageInteractionsHarness({
|
||||
activateCanvasGenerationDialog = vi.fn(),
|
||||
moveViewportFromMinimapPointer = vi.fn(),
|
||||
updateViewportFromMinimapDrag = vi.fn(),
|
||||
flushMinimapViewportDrag = vi.fn(),
|
||||
onViewportInteractionStart = vi.fn(),
|
||||
onViewportInteractionEnd = vi.fn(),
|
||||
}: {
|
||||
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
|
||||
@@ -133,6 +136,9 @@ function StageInteractionsHarness({
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => void;
|
||||
flushMinimapViewportDrag?: () => void;
|
||||
onViewportInteractionStart?: () => void;
|
||||
onViewportInteractionEnd?: () => void;
|
||||
}) {
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const worldRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -227,7 +233,10 @@ function StageInteractionsHarness({
|
||||
clientX,
|
||||
clientY,
|
||||
),
|
||||
flushMinimapViewportDrag,
|
||||
minimapScale: 0.5,
|
||||
onViewportInteractionStart,
|
||||
onViewportInteractionEnd,
|
||||
onCloseImageContextMenu: () =>
|
||||
setImageMenuCloseCount((currentCount) => currentCount + 1),
|
||||
});
|
||||
@@ -614,6 +623,20 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接拖小地图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
interaction.finishDrag(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 7,
|
||||
clientX: 130,
|
||||
clientY: 95,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
直接结束小地图拖拽
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<StageInteractionsHarness
|
||||
flushMinimapViewportDrag={flushMinimapViewportDrag}
|
||||
onViewportInteractionStart={onViewportInteractionStart}
|
||||
onViewportInteractionEnd={onViewportInteractionEnd}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DragState | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const suppressNextLayerClickRef = useRef(false);
|
||||
const isViewportInteractionActiveRef = useRef(false);
|
||||
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||
null,
|
||||
);
|
||||
@@ -135,12 +142,35 @@ export function useImageCanvasStageInteractions({
|
||||
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(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(
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const dragState = {
|
||||
kind: 'minimap' as const,
|
||||
pointerId: 2,
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
startViewport: { x: -100, y: -50, scale: 1 },
|
||||
minimapScale: controls.minimapModel?.scale ?? 1,
|
||||
moved: true,
|
||||
};
|
||||
controls.updateViewportFromMinimapDrag(dragState, 104, 103);
|
||||
controls.updateViewportFromMinimapDrag(dragState, 112, 109);
|
||||
}}
|
||||
>
|
||||
minimap drag twice
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.flushMinimapViewportDrag()}
|
||||
>
|
||||
flush minimap drag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.moveViewportFromMinimapPointer(100, 90)}
|
||||
@@ -146,6 +170,10 @@ function ViewportHarness({
|
||||
}
|
||||
|
||||
describe('useImageCanvasViewportControls', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('owns canvas size, fit view, center zoom and canvas point helpers', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
render(
|
||||
@@ -181,6 +209,14 @@ describe('useImageCanvasViewportControls', () => {
|
||||
});
|
||||
|
||||
it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
render(<ViewportHarness />);
|
||||
const viewportElement = screen.getByTestId('viewport-element');
|
||||
|
||||
@@ -206,6 +242,9 @@ describe('useImageCanvasViewportControls', () => {
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag' }).click();
|
||||
});
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toContain('-');
|
||||
|
||||
const beforeClick = screen.getByTestId('viewport').textContent;
|
||||
@@ -214,4 +253,71 @@ describe('useImageCanvasViewportControls', () => {
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).not.toBe(beforeClick);
|
||||
});
|
||||
|
||||
it('coalesces minimap drag viewport updates into one animation frame', () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
render(<ViewportHarness />);
|
||||
|
||||
const initialViewport = screen.getByTestId('viewport').textContent;
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag twice' }).click();
|
||||
});
|
||||
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(initialViewport);
|
||||
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
const coalescedViewport = screen.getByTestId('viewport').textContent;
|
||||
expect(coalescedViewport).not.toBe(initialViewport);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag' }).click();
|
||||
});
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).not.toBe(
|
||||
coalescedViewport,
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes a pending minimap drag before the next animation frame', () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
const cancelAnimationFrameSpy = vi
|
||||
.spyOn(window, 'cancelAnimationFrame')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
);
|
||||
render(<ViewportHarness />);
|
||||
const initialViewport = screen.getByTestId('viewport').textContent;
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag twice' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'flush minimap drag' }).click();
|
||||
});
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1);
|
||||
const flushedViewport = screen.getByTestId('viewport').textContent;
|
||||
expect(flushedViewport).not.toBe(initialViewport);
|
||||
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(flushedViewport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -48,6 +49,12 @@ export function useImageCanvasViewportControls({
|
||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||
);
|
||||
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
|
||||
const pendingMinimapDragRef = useRef<{
|
||||
dragState: Extract<DragState, { kind: 'minimap' }>;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null>(null);
|
||||
const minimapDragFrameRef = useRef<number | null>(null);
|
||||
|
||||
const minimapModel = useMemo(
|
||||
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||
@@ -182,18 +189,59 @@ export function useImageCanvasViewportControls({
|
||||
[canvasSize, minimapModel],
|
||||
);
|
||||
|
||||
const flushMinimapViewportDrag = useCallback(() => {
|
||||
if (minimapDragFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(minimapDragFrameRef.current);
|
||||
minimapDragFrameRef.current = null;
|
||||
}
|
||||
const pendingDrag = pendingMinimapDragRef.current;
|
||||
pendingMinimapDragRef.current = null;
|
||||
if (!pendingDrag) {
|
||||
return;
|
||||
}
|
||||
setViewport(
|
||||
resolveViewportFromMinimapDrag(pendingDrag.dragState, {
|
||||
x: pendingDrag.clientX,
|
||||
y: pendingDrag.clientY,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateViewportFromMinimapDrag = useCallback(
|
||||
(
|
||||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
setViewport(
|
||||
resolveViewportFromMinimapDrag(dragState, {
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
}),
|
||||
);
|
||||
pendingMinimapDragRef.current = { dragState, clientX, clientY };
|
||||
if (minimapDragFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
minimapDragFrameRef.current = window.requestAnimationFrame(() => {
|
||||
minimapDragFrameRef.current = null;
|
||||
const pendingDrag = pendingMinimapDragRef.current;
|
||||
pendingMinimapDragRef.current = null;
|
||||
if (!pendingDrag) {
|
||||
return;
|
||||
}
|
||||
setViewport(
|
||||
resolveViewportFromMinimapDrag(pendingDrag.dragState, {
|
||||
x: pendingDrag.clientX,
|
||||
y: pendingDrag.clientY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (minimapDragFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(minimapDragFrameRef.current);
|
||||
minimapDragFrameRef.current = null;
|
||||
}
|
||||
pendingMinimapDragRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -254,5 +302,6 @@ export function useImageCanvasViewportControls({
|
||||
getCanvasPointFromClient,
|
||||
moveViewportFromMinimapPointer,
|
||||
updateViewportFromMinimapDrag,
|
||||
flushMinimapViewportDrag,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user