From 79c3748b0eb2a73d4b27d90644c82a0f0180f3c6 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 09:47:05 +0200 Subject: [PATCH] feat(input): add viewport wheel zoom and panning --- input/index.ts | 3 +- input/viewport.ts | 56 ++++++++++++++++++++++++++++- view/CanvasViewport.tsx | 78 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/input/index.ts b/input/index.ts index f6043d9..0050258 100644 --- a/input/index.ts +++ b/input/index.ts @@ -6,4 +6,5 @@ export { export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; -export { handleViewportWheel } from "./viewport"; +export type { ViewportPointerPanHandler } from "./viewport"; +export { createViewportPointerPanHandler, handleViewportWheel } from "./viewport"; diff --git a/input/viewport.ts b/input/viewport.ts index 13b9b5d..955363b 100644 --- a/input/viewport.ts +++ b/input/viewport.ts @@ -1,5 +1,59 @@ import type { Dispatch } from "@commands/dispatcher"; -import type { GlobalWheelConsumer, WheelInputEvent } from "./pointer"; +import type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; + +export type ViewportPointerPanHandler = { + pointerDown(event: PointerInputEvent): boolean; + pointerMove(event: PointerInputEvent): boolean; + pointerUp(event: PointerInputEvent): boolean; +}; + +export function createViewportPointerPanHandler(options: { + globalConsumer: GlobalPointerConsumer; + dispatch: Dispatch; + getCurrentZoom: () => number; +}): ViewportPointerPanHandler { + let activePointerId: number | undefined; + let lastPosition: PointerInputEvent["position"] | undefined; + + const canPan = (event: PointerInputEvent) => event.pointerType !== "mouse" || (event.buttons & 4) === 4; + + return { + pointerDown(event) { + if (options.globalConsumer(event)) return true; + if (!canPan(event)) return false; + + activePointerId = event.pointerId; + lastPosition = event.position; + return true; + }, + pointerMove(event) { + if (activePointerId !== event.pointerId || !lastPosition) return false; + + const zoom = options.getCurrentZoom(); + const screenDelta = { + x: event.position.x - lastPosition.x, + y: event.position.y - lastPosition.y, + }; + + lastPosition = event.position; + options.dispatch("viewport.pan", { + delta: { + x: -screenDelta.x / zoom, + y: -screenDelta.y / zoom, + }, + }); + + return true; + }, + pointerUp(event) { + if (activePointerId !== event.pointerId) return false; + + activePointerId = undefined; + lastPosition = undefined; + return true; + }, + }; +} export function handleViewportWheel(options: { event: WheelInputEvent; diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index 23ffe9f..7219453 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,12 +1,28 @@ import { useEffect, useRef } from "react"; import type { AppStore } from "@editor/store"; +import type { GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; +import { + createViewportPointerPanHandler, + handleViewportWheel, + pointerInputEventFromPointerEvent, + wheelInputEventFromWheelEvent, +} from "@input/index"; import { createRenderer } from "@renderer/index"; +const ignoreGlobalPointer: GlobalPointerConsumer = () => false; +const ignoreGlobalWheel: GlobalWheelConsumer = () => false; + export type CanvasViewportProps = { store: AppStore; + globalPointerConsumer?: GlobalPointerConsumer; + globalWheelConsumer?: GlobalWheelConsumer; }; -export function CanvasViewport({ store }: CanvasViewportProps) { +export function CanvasViewport({ + store, + globalPointerConsumer = ignoreGlobalPointer, + globalWheelConsumer = ignoreGlobalWheel, +}: CanvasViewportProps) { const canvasRef = useRef(null); useEffect(() => { @@ -42,5 +58,65 @@ export function CanvasViewport({ store }: CanvasViewportProps) { return () => resizeObserver.disconnect(); }, [store]); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const panHandler = createViewportPointerPanHandler({ + globalConsumer: globalPointerConsumer, + dispatch: store.dispatch, + getCurrentZoom: () => store.getState().editor.viewport.zoom, + }); + + const handlePointerDown = (event: PointerEvent) => { + const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); + if (!consumed) return; + + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event)); + if (consumed) event.preventDefault(); + }; + + const handlePointerUp = (event: PointerEvent) => { + const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event)); + if (consumed) event.preventDefault(); + }; + + canvas.addEventListener("pointerdown", handlePointerDown); + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", handlePointerUp); + canvas.addEventListener("pointercancel", handlePointerUp); + + return () => { + canvas.removeEventListener("pointerdown", handlePointerDown); + canvas.removeEventListener("pointermove", handlePointerMove); + canvas.removeEventListener("pointerup", handlePointerUp); + canvas.removeEventListener("pointercancel", handlePointerUp); + }; + }, [globalPointerConsumer, store]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleWheel = (event: WheelEvent) => { + const consumed = handleViewportWheel({ + event: wheelInputEventFromWheelEvent(event), + globalConsumer: globalWheelConsumer, + dispatch: store.dispatch, + currentZoom: store.getState().editor.viewport.zoom, + }); + + if (consumed) event.preventDefault(); + }; + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + }, [globalWheelConsumer, store]); + return ; }