/** * 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 * * Pointer-based pan (middle-click, space-drag) and marquee selection * remain owned by each rApp because they depend on per-rApp hit-testing * and selection state. * * Usage: * const controller = new CanvasInteractionController({ * target: svgEl, // element that receives wheel/touch events * 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(), * }); * // ...on teardown: * controller.destroy(); */ export interface Viewport { x: number; y: number; zoom: number; } 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): boolean; } 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; // 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; 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); } /** 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); } /** * 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?.(); } 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; } } } 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); }