diff --git a/input/index.ts b/input/index.ts index 0050258..11f16cc 100644 --- a/input/index.ts +++ b/input/index.ts @@ -5,6 +5,8 @@ export { } from "./dom"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; +export type { ViewportPanInputController } from "./viewport-pan"; +export { createViewportPanInputController } from "./viewport-pan"; export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; export type { ViewportPointerPanHandler } from "./viewport"; export { createViewportPointerPanHandler, handleViewportWheel } from "./viewport"; diff --git a/input/viewport-pan.test.ts b/input/viewport-pan.test.ts new file mode 100644 index 0000000..8fb0aed --- /dev/null +++ b/input/viewport-pan.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { createViewportPanInputController } from "./viewport-pan"; + +const ignoredState = undefined as never; + +describe("viewport pan input controller", () => { + test("space enters and exits temporary pan through commands", () => { + const dispatched: unknown[] = []; + const controller = createViewportPanInputController({ + globalKeyConsumer: () => false, + globalPointerConsumer: () => false, + getCurrentZoom: () => 1, + isPanMode: () => false, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(controller.keyDown(keyEvent("Space"))).toBe(true); + expect(controller.keyUp(keyEvent("Space"))).toBe(true); + expect(dispatched).toEqual([ + { commandId: "tool.enterTemporaryPan", payload: undefined }, + { commandId: "tool.exitTemporaryPan", payload: undefined }, + ]); + }); + + test("global key consumer prevents temporary pan command", () => { + let dispatched = false; + const controller = createViewportPanInputController({ + globalKeyConsumer: () => true, + globalPointerConsumer: () => false, + getCurrentZoom: () => 1, + isPanMode: () => false, + dispatch: () => { + dispatched = true; + return ignoredState; + }, + }); + + expect(controller.keyDown(keyEvent("Space"))).toBe(true); + expect(dispatched).toBe(false); + }); +}); + +function keyEvent(code: string) { + return { key: code === "Space" ? " " : code, code, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }; +} diff --git a/input/viewport-pan.ts b/input/viewport-pan.ts new file mode 100644 index 0000000..3989f98 --- /dev/null +++ b/input/viewport-pan.ts @@ -0,0 +1,48 @@ +import type { Dispatch } from "@commands/dispatcher"; +import type { GlobalKeybindConsumer, KeybindEvent } from "./keyboard"; +import type { GlobalPointerConsumer, PointerInputEvent } from "./pointer"; +import { createViewportPointerPanHandler } from "./viewport"; + +export type ViewportPanInputController = { + keyDown(event: KeybindEvent): boolean; + keyUp(event: KeybindEvent): boolean; + pointerDown(event: PointerInputEvent): boolean; + pointerMove(event: PointerInputEvent): boolean; + pointerUp(event: PointerInputEvent): boolean; + isPanMode(): boolean; +}; + +export function createViewportPanInputController(options: { + globalKeyConsumer: GlobalKeybindConsumer; + globalPointerConsumer: GlobalPointerConsumer; + dispatch: Dispatch; + getCurrentZoom: () => number; + isPanMode: () => boolean; +}): ViewportPanInputController { + const pointerPan = createViewportPointerPanHandler({ + globalConsumer: options.globalPointerConsumer, + dispatch: options.dispatch, + getCurrentZoom: options.getCurrentZoom, + canPan: (event) => event.pointerType !== "mouse" || (event.buttons & 4) === 4 || (options.isPanMode() && (event.buttons & 1) === 1), + }); + + return { + keyDown(event) { + if (options.globalKeyConsumer(event)) return true; + if (event.code !== "Space") return false; + + options.dispatch("tool.enterTemporaryPan", undefined); + return true; + }, + keyUp(event) { + if (event.code !== "Space") return false; + + options.dispatch("tool.exitTemporaryPan", undefined); + return true; + }, + pointerDown: pointerPan.pointerDown, + pointerMove: pointerPan.pointerMove, + pointerUp: pointerPan.pointerUp, + isPanMode: options.isPanMode, + }; +} diff --git a/input/viewport.ts b/input/viewport.ts index 955363b..1139871 100644 --- a/input/viewport.ts +++ b/input/viewport.ts @@ -11,11 +11,12 @@ export function createViewportPointerPanHandler(options: { globalConsumer: GlobalPointerConsumer; dispatch: Dispatch; getCurrentZoom: () => number; + canPan?: (event: PointerInputEvent) => boolean; }): ViewportPointerPanHandler { let activePointerId: number | undefined; let lastPosition: PointerInputEvent["position"] | undefined; - const canPan = (event: PointerInputEvent) => event.pointerType !== "mouse" || (event.buttons & 4) === 4; + const canPan = options.canPan ?? defaultCanPan; return { pointerDown(event) { @@ -55,6 +56,10 @@ export function createViewportPointerPanHandler(options: { }; } +function defaultCanPan(event: PointerInputEvent) { + return event.pointerType !== "mouse" || (event.buttons & 4) === 4; +} + export function handleViewportWheel(options: { event: WheelInputEvent; globalConsumer: GlobalWheelConsumer; diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index fa0816c..884ef2c 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,29 +1,35 @@ import { useEffect, useRef, useState } from "react"; import type { AppStore } from "@editor/store"; -import type { GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; +import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; import { - createViewportPointerPanHandler, + createViewportPanInputController, handleViewportWheel, + keybindEventFromKeyboardEvent, pointerInputEventFromPointerEvent, wheelInputEventFromWheelEvent, } from "@input/index"; import { createRenderer } from "@renderer/index"; +import { useAppState } from "./useAppState"; +const ignoreGlobalKeybind: GlobalKeybindConsumer = () => false; const ignoreGlobalPointer: GlobalPointerConsumer = () => false; const ignoreGlobalWheel: GlobalWheelConsumer = () => false; export type CanvasViewportProps = { store: AppStore; + globalKeybindConsumer?: GlobalKeybindConsumer; globalPointerConsumer?: GlobalPointerConsumer; globalWheelConsumer?: GlobalWheelConsumer; }; export function CanvasViewport({ store, + globalKeybindConsumer = ignoreGlobalKeybind, globalPointerConsumer = ignoreGlobalPointer, globalWheelConsumer = ignoreGlobalWheel, }: CanvasViewportProps) { const canvasRef = useRef(null); + const state = useAppState(store); const [isPanning, setIsPanning] = useState(false); useEffect(() => { @@ -63,12 +69,24 @@ export function CanvasViewport({ const canvas = canvasRef.current; if (!canvas) return; - const panHandler = createViewportPointerPanHandler({ - globalConsumer: globalPointerConsumer, + const panHandler = createViewportPanInputController({ + globalKeyConsumer: globalKeybindConsumer, + globalPointerConsumer, dispatch: store.dispatch, getCurrentZoom: () => store.getState().editor.viewport.zoom, + isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan", }); + const handleKeyDown = (event: KeyboardEvent) => { + const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event)); + if (consumed) event.preventDefault(); + }; + + const handleKeyUp = (event: KeyboardEvent) => { + const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event)); + if (consumed) event.preventDefault(); + }; + const handlePointerDown = (event: PointerEvent) => { const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); if (!consumed) return; @@ -91,18 +109,22 @@ export function CanvasViewport({ event.preventDefault(); }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); canvas.addEventListener("pointerdown", handlePointerDown); canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerup", handlePointerUp); canvas.addEventListener("pointercancel", handlePointerUp); return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); canvas.removeEventListener("pointerdown", handlePointerDown); canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerup", handlePointerUp); canvas.removeEventListener("pointercancel", handlePointerUp); }; - }, [globalPointerConsumer, store]); + }, [globalKeybindConsumer, globalPointerConsumer, store]); useEffect(() => { const canvas = canvasRef.current; @@ -123,5 +145,8 @@ export function CanvasViewport({ return () => canvas.removeEventListener("wheel", handleWheel); }, [globalWheelConsumer, store]); - return ; + const isPanMode = state.editor.tools.interactionMode.type === "temporary-pan"; + const cursorClass = isPanning ? "cursor-grabbing" : isPanMode ? "cursor-grab" : "cursor-default"; + + return ; }