refactor(canvas): install wheel zoom via a passive-safe native listener

Move canvas wheel zoom off the React onWheel prop into a manually attached
non-passive listener so preventDefault reliably suppresses page scroll, and
read the viewport from the ref to keep the handler referentially stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:40:39 +08:00
parent f36222c6c6
commit 57a9aba03e
3 changed files with 46 additions and 6 deletions
@@ -0,0 +1,4 @@
export function installCanvasWheelListener(target: HTMLElement, listener: (event: WheelEvent) => void) {
target.addEventListener("wheel", listener, { passive: false });
return () => target.removeEventListener("wheel", listener);
}
@@ -48,6 +48,7 @@ import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { installCanvasWheelListener } from "./canvas/canvasWheel";
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
@@ -1233,26 +1234,33 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
return () => window.clearTimeout(timer);
}, [imageActionMode, locale, moveSelection, selectedImageNode?.content]);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const handleWheel = useCallback((event: WheelEvent) => {
if (shouldBypassCanvasWheel(event.target)) {
return;
}
event.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const currentViewport = viewportRef.current;
const factor = Math.pow(1.1, -event.deltaY / 120);
const nextScale = clamp(viewport.k * factor, minCanvasZoom, maxCanvasZoom);
const nextScale = clamp(currentViewport.k * factor, minCanvasZoom, maxCanvasZoom);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
const worldX = (mouseX - currentViewport.x) / currentViewport.k;
const worldY = (mouseY - currentViewport.y) / currentViewport.k;
setViewport({
x: mouseX - worldX * nextScale,
y: mouseY - worldY * nextScale,
k: nextScale
});
setDirty(true);
};
}, [setViewport, viewportRef]);
useEffect(() => {
const stage = containerRef.current;
if (!stage) return;
return installCanvasWheelListener(stage, handleWheel);
}, [handleWheel, project?.id]);
const zoomCanvasTo = (nextZoom: number) => {
const current = viewportRef.current;
@@ -3603,7 +3611,6 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""}`}
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
ref={containerRef}
onWheel={handleWheel}
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
onPointerMove={handleCanvasPointerMove}
onDragOver={canEdit ? handleCanvasDragOver : undefined}
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { installCanvasWheelListener } from "../src/ui/pages/CanvasWorkspace/canvas/canvasWheel.ts";
test("installs the canvas wheel handler as non-passive and removes it cleanly", () => {
const calls = [];
const target = {
addEventListener(type, listener, options) {
calls.push({ action: "add", type, listener, options });
},
removeEventListener(type, listener) {
calls.push({ action: "remove", type, listener });
}
};
const listener = () => {};
const cleanup = installCanvasWheelListener(target, listener);
assert.equal(calls[0].action, "add");
assert.equal(calls[0].type, "wheel");
assert.strictEqual(calls[0].listener, listener);
assert.deepEqual(calls[0].options, { passive: false });
cleanup();
assert.equal(calls[1].action, "remove");
assert.equal(calls[1].type, "wheel");
assert.strictEqual(calls[1].listener, listener);
});