/** * CanvasInteractionController — shared pan/zoom for rApp mini-canvases. * * Mirrors the interaction model of the main rSpace canvas * (website/canvas.html): * • Wheel / two-finger scroll → PAN * • Ctrl/Cmd+wheel or pinch → ZOOM at cursor * • Two-finger touch → PAN + pinch-zoom at gesture center * • Space (opt-in) → grab cursor; rApp drives pointer pan * • Keyboard shortcuts (opt-in) → 0 fit, +/- zoom, arrows pan * * Pointer-based pan and marquee selection remain owned by each rApp * because they depend on per-rApp hit-testing and selection state — * but the controller exposes `isSpaceHeld()` so rApps can branch on it. * * Usage: * const controller = new CanvasInteractionController({ * target: svgEl, * getViewport: () => ({ x: this.panX, y: this.panY, zoom: this.scale }), * setViewport: v => { this.panX = v.x; this.panY = v.y; this.scale = v.zoom; }, * onChange: () => this.updateTransform(), * enableKeyboardShortcuts: { fit: () => this.fitView() }, * enableSpaceToGrab: true, * }); * // ...on teardown: * controller.destroy(); */ export interface Viewport { x: number; y: number; zoom: number; } export interface KeyboardShortcutHandlers { /** Called on `0`. rApp fits all nodes into view. */ fit?: () => void; /** Called on `Ctrl/Cmd+Z`. */ undo?: () => void; /** Called on `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`. */ redo?: () => void; /** Called on `Delete`/`Backspace` when target is focused. */ deleteSelected?: () => void; } export interface CanvasInteractionOptions { /** Element that wheel + touch events attach to. */ target: HTMLElement | SVGElement; /** Current viewport getter. */ getViewport(): Viewport; /** Apply a new viewport. */ setViewport(v: Viewport): void; /** Called after viewport mutates; consumer updates transform. */ onChange?(): void; /** Clamp bounds (default 0.1 .. 4). */ minZoom?: number; maxZoom?: number; /** Per-wheel-tick zoom factor (default 0.01, scaled by deltaY). */ wheelZoomStep?: number; /** * Gate predicate. Return false to ignore an event (e.g. a tool overlay is * active). Receives the event for inspection. */ isEnabled?(e: WheelEvent | TouchEvent | KeyboardEvent): boolean; /** * Opt-in: listen for Space on `document` and flip the target cursor * to `grab`. The rApp's own pointerdown handler should check * `controller.isSpaceHeld()` to decide whether to enter pan mode. */ enableSpaceToGrab?: boolean; /** * Opt-in: keyboard shortcuts for fit, undo/redo, delete, and zoom * (`0`, `Ctrl+Z`, `Ctrl+Shift+Z`, `Delete`, `+`, `-`, arrow keys). * Arrow keys pan the viewport by `ARROW_PAN_STEP` px. `+`/`-` zoom * around the viewport center. */ enableKeyboardShortcuts?: KeyboardShortcutHandlers | boolean; } const ARROW_PAN_STEP = 40; const BUTTON_ZOOM_FACTOR = 1.25; export class CanvasInteractionController { private target: HTMLElement | SVGElement; private opts: CanvasInteractionOptions; private minZoom: number; private maxZoom: number; private wheelZoomStep: number; // Touch gesture state private lastTouchCenter: { x: number; y: number } | null = null; private lastTouchDist: number | null = null; private isTouchGesture = false; // Space-to-grab state private spaceHeld = false; private previousCursor = ""; // Bound handlers (so we can remove them) private readonly onWheel: (e: WheelEvent) => void; private readonly onTouchStart: (e: TouchEvent) => void; private readonly onTouchMove: (e: TouchEvent) => void; private readonly onTouchEnd: (e: TouchEvent) => void; private readonly onKeyDown: ((e: KeyboardEvent) => void) | null; private readonly onKeyUp: ((e: KeyboardEvent) => void) | null; constructor(opts: CanvasInteractionOptions) { this.target = opts.target; this.opts = opts; this.minZoom = opts.minZoom ?? 0.1; this.maxZoom = opts.maxZoom ?? 4; this.wheelZoomStep = opts.wheelZoomStep ?? 0.01; this.onWheel = this.handleWheel.bind(this); this.onTouchStart = this.handleTouchStart.bind(this); this.onTouchMove = this.handleTouchMove.bind(this); this.onTouchEnd = this.handleTouchEnd.bind(this); this.target.addEventListener("wheel", this.onWheel as EventListener, { passive: false }); this.target.addEventListener("touchstart", this.onTouchStart as EventListener, { passive: false }); this.target.addEventListener("touchmove", this.onTouchMove as EventListener, { passive: false }); this.target.addEventListener("touchend", this.onTouchEnd as EventListener); // Keyboard is on `document` because targets often lack focus. const wantKeys = !!opts.enableKeyboardShortcuts; const wantSpace = !!opts.enableSpaceToGrab; if (wantKeys || wantSpace) { this.onKeyDown = this.handleKeyDown.bind(this); this.onKeyUp = this.handleKeyUp.bind(this); document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keyup", this.onKeyUp); } else { this.onKeyDown = null; this.onKeyUp = null; } } /** Detach all event listeners. */ destroy(): void { this.target.removeEventListener("wheel", this.onWheel as EventListener); this.target.removeEventListener("touchstart", this.onTouchStart as EventListener); this.target.removeEventListener("touchmove", this.onTouchMove as EventListener); this.target.removeEventListener("touchend", this.onTouchEnd as EventListener); if (this.onKeyDown) document.removeEventListener("keydown", this.onKeyDown); if (this.onKeyUp) document.removeEventListener("keyup", this.onKeyUp); // Restore cursor if we still had it set if (this.spaceHeld) { (this.target as HTMLElement).style.cursor = this.previousCursor; } } /** * Programmatic zoom around a screen-space point (relative to target). * Useful for +/- UI buttons and fit-view transitions. */ zoomAt(screenX: number, screenY: number, factor: number): void { const v = this.opts.getViewport(); const newZoom = this.clampZoom(v.zoom * factor); if (newZoom === v.zoom) return; const ratio = newZoom / v.zoom; const next: Viewport = { x: screenX - (screenX - v.x) * ratio, y: screenY - (screenY - v.y) * ratio, zoom: newZoom, }; this.opts.setViewport(next); this.opts.onChange?.(); } /** Programmatic pan (screen-space delta). */ panBy(dx: number, dy: number): void { const v = this.opts.getViewport(); this.opts.setViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom }); this.opts.onChange?.(); } /** Zoom centered on the target element. Used by chrome +/- buttons. */ zoomByFactor(factor: number): void { const rect = this.target.getBoundingClientRect(); this.zoomAt(rect.width / 2, rect.height / 2, factor); } /** Is Space currently held down? rApps can check this in pointerdown. */ isSpaceHeld(): boolean { return this.spaceHeld; } private clampZoom(z: number): number { return Math.min(this.maxZoom, Math.max(this.minZoom, z)); } private handleWheel(e: WheelEvent): void { if (this.opts.isEnabled && !this.opts.isEnabled(e)) return; e.preventDefault(); const rect = this.target.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Ctrl/Cmd+wheel or trackpad pinch (ctrlKey synthesized by browsers) = zoom if (e.ctrlKey || e.metaKey) { const factor = 1 - e.deltaY * this.wheelZoomStep; this.zoomAt(mx, my, factor); return; } // Regular two-finger scroll / wheel = pan this.panBy(-e.deltaX, -e.deltaY); } private handleTouchStart(e: TouchEvent): void { if (this.opts.isEnabled && !this.opts.isEnabled(e)) return; if (e.touches.length === 2) { e.preventDefault(); this.isTouchGesture = true; this.lastTouchCenter = touchCenter(e.touches); this.lastTouchDist = touchDist(e.touches); } } private handleTouchMove(e: TouchEvent): void { if (this.opts.isEnabled && !this.opts.isEnabled(e)) return; if (e.touches.length !== 2 || !this.isTouchGesture) return; e.preventDefault(); const center = touchCenter(e.touches); const dist = touchDist(e.touches); const v = this.opts.getViewport(); let nx = v.x; let ny = v.y; let nz = v.zoom; if (this.lastTouchCenter) { nx += center.x - this.lastTouchCenter.x; ny += center.y - this.lastTouchCenter.y; } if (this.lastTouchDist && this.lastTouchDist > 0) { const zoomDelta = dist / this.lastTouchDist; const newZoom = this.clampZoom(nz * zoomDelta); const rect = this.target.getBoundingClientRect(); const cx = center.x - rect.left; const cy = center.y - rect.top; const ratio = newZoom / nz; nx = cx - (cx - nx) * ratio; ny = cy - (cy - ny) * ratio; nz = newZoom; } this.opts.setViewport({ x: nx, y: ny, zoom: nz }); this.opts.onChange?.(); this.lastTouchCenter = center; this.lastTouchDist = dist; } private handleTouchEnd(e: TouchEvent): void { if (e.touches.length < 2) { this.isTouchGesture = false; this.lastTouchCenter = null; this.lastTouchDist = null; } } private handleKeyDown(e: KeyboardEvent): void { // Bail on text inputs (rApps often have inline editors). if (isInTextInput(e)) return; if (this.opts.isEnabled && !this.opts.isEnabled(e)) return; // Space-to-grab (opt-in). if (this.opts.enableSpaceToGrab && e.code === "Space" && !this.spaceHeld) { e.preventDefault(); this.spaceHeld = true; this.previousCursor = (this.target as HTMLElement).style.cursor || ""; (this.target as HTMLElement).style.cursor = "grab"; } const ks = this.opts.enableKeyboardShortcuts; if (!ks) return; const handlers: KeyboardShortcutHandlers = ks === true ? {} : ks; // Fit view: `0` if (e.key === "0" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) { e.preventDefault(); handlers.fit?.(); return; } // Zoom: `+` / `=` / `-` if ((e.key === "+" || e.key === "=") && !e.ctrlKey && !e.metaKey) { e.preventDefault(); this.zoomByFactor(BUTTON_ZOOM_FACTOR); return; } if (e.key === "-" && !e.ctrlKey && !e.metaKey) { e.preventDefault(); this.zoomByFactor(1 / BUTTON_ZOOM_FACTOR); return; } // Arrow pan if (e.key === "ArrowLeft") { e.preventDefault(); this.panBy(ARROW_PAN_STEP, 0); return; } if (e.key === "ArrowRight") { e.preventDefault(); this.panBy(-ARROW_PAN_STEP, 0); return; } if (e.key === "ArrowUp") { e.preventDefault(); this.panBy(0, ARROW_PAN_STEP); return; } if (e.key === "ArrowDown") { e.preventDefault(); this.panBy(0, -ARROW_PAN_STEP); return; } // Undo / Redo if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (e.shiftKey) handlers.redo?.(); else handlers.undo?.(); return; } if ((e.key === "y" || e.key === "Y") && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handlers.redo?.(); return; } // Delete selected if ((e.key === "Delete" || e.key === "Backspace") && handlers.deleteSelected) { e.preventDefault(); handlers.deleteSelected(); return; } } private handleKeyUp(e: KeyboardEvent): void { if (e.code === "Space" && this.spaceHeld) { this.spaceHeld = false; (this.target as HTMLElement).style.cursor = this.previousCursor; } } } function touchCenter(ts: TouchList): { x: number; y: number } { return { x: (ts[0].clientX + ts[1].clientX) / 2, y: (ts[0].clientY + ts[1].clientY) / 2, }; } function touchDist(ts: TouchList): number { const dx = ts[0].clientX - ts[1].clientX; const dy = ts[0].clientY - ts[1].clientY; return Math.hypot(dx, dy); } /** * Shadow-DOM-aware text input detector. Keyboard events are retargeted * to the shadow host, so we walk `composedPath()` for true targets. */ function isInTextInput(e: KeyboardEvent): boolean { return e.composedPath().some(el => el instanceof HTMLElement && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable), ); }