优化图片画布拖动性能 (#173)
Project CI / Frontend tests (push) Failing after 45s
Project CI / Native shell tests (push) Failing after 46s
Project CI / Repository checks (push) Failing after 47s
Project CI / Backend tests (push) Successful in 3m25s

## 修改

- 按动画帧合并高频拖动更新,结束时持久化最终状态
- 对未移动图层子树做 memo,保留并发资源与业务字段更新
- 补充拖动合帧、最终 flush、并发状态和图层渲染回归
- 同步真实位图性能验收与排障文档

## 验证

- 8 个相关测试文件 202/202 通过
- TypeScript typecheck、编码检查、Prettier、git diff --check 通过
- 12000×12000 画布、360 张独立 512×512 PNG:有效位置更新 23.44/s → 58.18/s,gap p95 119.8ms → 32.9ms,>50ms 停顿 32 → 0,持久化快照 240 → 1
- 可信 CDP 输入最终运动观测约 46.9 FPS,不以空转 rAF 代替拖动帧率

Reviewed-on: #173
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #173.
This commit is contained in:
2026-08-21 15:33:02 +08:00
committed by 段舒康
parent f25fb2a768
commit c39d63b65f
6 changed files with 881 additions and 224 deletions
@@ -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<DragState | null>(null);
const pendingDragHistoryRef = useRef<PendingDragHistory | null>(null);
const pendingClickCollapseRef = useRef<PendingClickCollapse | null>(null);
const pendingPointerMoveRef = useRef<PendingPointerMove | null>(null);
const pointerMoveFrameRef = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,