优化图片画布拖动性能 (#173)
## 修改 - 按动画帧合并高频拖动更新,结束时持久化最终状态 - 对未移动图层子树做 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user