From 57a9aba03e25f5c99e28a3e51fc342b2d3c5d34e Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 16 Jul 2026 16:40:39 +0800 Subject: [PATCH] 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) --- .../CanvasWorkspace/canvas/canvasWheel.ts | 4 +++ .../src/ui/pages/CanvasWorkspace/index.tsx | 19 ++++++++---- frontend/tests/canvasWheel.test.mjs | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/canvasWheel.ts create mode 100644 frontend/tests/canvasWheel.test.mjs diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasWheel.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasWheel.ts new file mode 100644 index 0000000..8a60246 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/canvasWheel.ts @@ -0,0 +1,4 @@ +export function installCanvasWheelListener(target: HTMLElement, listener: (event: WheelEvent) => void) { + target.addEventListener("wheel", listener, { passive: false }); + return () => target.removeEventListener("wheel", listener); +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index 9732f38..fdad79a 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -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) => { + 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} diff --git a/frontend/tests/canvasWheel.test.mjs b/frontend/tests/canvasWheel.test.mjs new file mode 100644 index 0000000..7fb5873 --- /dev/null +++ b/frontend/tests/canvasWheel.test.mjs @@ -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); +});