优化画布视口拖动性能

小地图拖动通过 requestAnimationFrame 合帧并在结束时刷新最后一帧

平移和小地图拖动期间跳过项目自动保存与 session 缓存写入

补充视口交互性能回归测试和项目文档记录
This commit is contained in:
2026-06-25 23:50:24 +08:00
parent 0876361557
commit bf09b84cb3
9 changed files with 399 additions and 26 deletions
@@ -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,
};
}