优化画布视口拖动性能

小地图拖动通过 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
@@ -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,
};
}