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); +});