import { Point, Size, roundPoint } from "@noya-app/noya-geometry"; import { wheelValues } from "../utils/wheelUtils"; import { createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, HandleKeyboardEventProperties, ScreenPointProperties, } from "./contextProperties"; import { NoneMode, modeSwitch } from "./PluginSystem"; type ReturnTo = "none" | "panMode"; export type PanMode = | NoneMode | { type: "panMode" } | { type: "maybePan"; origin: Point; returnTo: ReturnTo } | { type: "panning"; previous: Point; next: Point; returnTo: ReturnTo }; export type PanData = { size: Size; origin: Point; }; export interface PanContextProperties extends BaseContextProperties, HandleKeyboardEventProperties, ScreenPointProperties { isHandActive?: () => boolean; } export type PanOptions = { onWheelShouldPreventDefault?: boolean; modiferKeyShouldDisablePan?: boolean; }; export function panPlugin(options: PanOptions = {}) { const { modiferKeyShouldDisablePan = true } = options; return createCanvasPlugin({ name: "pan", data: { size: { width: 0, height: 0 }, origin: { x: 0, y: 0 }, }, handlers: (context: PanContextProperties) => ({ onWheel: (event) => { // Skip panning when modifier key is pressed (for zoom functionality) if (modiferKeyShouldDisablePan && (event.metaKey || event.ctrlKey)) { return; } const data = context.getData(); const delta = wheelValues(event.nativeEvent); context.setData({ ...data, origin: roundPoint({ x: data.origin.x - delta.x, y: data.origin.y - delta.y, }), }); // React uses a passive listener, so we usually don't prevent the default if (options.onWheelShouldPreventDefault) { event.preventDefault(); } }, onKeyDown: (event) => modeSwitch(context.getMode(), { none: () => context.handleKeyboardEvent({ Space: () => { context.setMode({ type: "panMode" }); context.setReadOnly(true); }, })(event), }), onKeyUp: (event) => { const state = context.getMode(); if ( state.type === "maybePan" || state.type === "panning" || state.type === "panMode" ) { context.handleKeyboardEvent({ Space: () => { context.setMode({ type: "none" }); context.setReadOnly(false); }, })(event); } }, onPointerDown: (event) => { const mode = context.getMode(); const handActive = context.isHandActive?.() ?? false; if (handActive || mode.type === "panMode") { const point = context.getScreenPoint(event.nativeEvent); context.setMode({ type: "maybePan", origin: point, returnTo: handActive ? "none" : "panMode", }); event.preventDefault(); } }, onPointerMove: (event) => modeSwitch(context.getMode(), { maybePan: ({ origin, returnTo }) => { const point = context.getScreenPoint(event.nativeEvent); context.setMode({ type: "panning", previous: origin, next: point, returnTo: returnTo, }); event.preventDefault(); }, panning: ({ next, returnTo }) => { const data = context.getData(); const point = context.getScreenPoint(event.nativeEvent); context.setData({ ...data, origin: roundPoint({ x: data.origin.x + point.x - next.x, y: data.origin.y + point.y - next.y, }), }); context.setMode({ type: "panning", previous: next, next: point, returnTo: returnTo, }); }, }), onPointerUp: (event) => modeSwitch(context.getMode(), { maybePan: ({ returnTo }) => { context.setMode({ type: returnTo }); event.preventDefault(); }, panning: ({ returnTo }) => { context.setMode({ type: returnTo }); event.preventDefault(); }, }), }), getCursor: ({ mode }) => { if (mode.type === "panMode") { return "grab"; } else if (mode.type === "maybePan" || mode.type === "panning") { return "grabbing"; } return undefined; }, }); }