diff --git a/lib/applet-circuit-canvas.ts b/lib/applet-circuit-canvas.ts index 19ec8cd5..18167dae 100644 --- a/lib/applet-circuit-canvas.ts +++ b/lib/applet-circuit-canvas.ts @@ -8,6 +8,7 @@ */ import type { AppletSubNode, AppletSubEdge } from "../shared/applet-types"; +import { CanvasInteractionController } from "../shared/canvas-interaction"; const NODE_WIDTH = 200; const NODE_HEIGHT = 80; @@ -113,6 +114,7 @@ export class AppletCircuitCanvas extends HTMLElement { #panX = 0; #panY = 0; #zoom = 1; + #interactionController: CanvasInteractionController | null = null; #isPanning = false; #panStart = { x: 0, y: 0 }; @@ -257,20 +259,21 @@ export class AppletCircuitCanvas extends HTMLElement { this.#isPanning = false; }); - // Zoom - svg.addEventListener("wheel", (e) => { - e.preventDefault(); - const factor = e.deltaY > 0 ? 0.9 : 1.1; - const oldZoom = this.#zoom; - const newZoom = Math.max(0.2, Math.min(3, oldZoom * factor)); - const rect = svg.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - this.#panX = mx - (mx - this.#panX) * (newZoom / oldZoom); - this.#panY = my - (my - this.#panY) * (newZoom / oldZoom); - this.#zoom = newZoom; - this.#updateTransform(); - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.#interactionController?.destroy(); + this.#interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.#panX, y: this.#panY, zoom: this.#zoom }), + setViewport: (v) => { this.#panX = v.x; this.#panY = v.y; this.#zoom = v.zoom; }, + onChange: () => this.#updateTransform(), + minZoom: 0.2, + maxZoom: 3, + }); + } + + disconnectedCallback() { + this.#interactionController?.destroy(); + this.#interactionController = null; } } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 2ee43c02..ebe9bdab 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -21,6 +21,7 @@ import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../sche import type { DocumentId } from "../../../shared/local-first/document"; import { FlowsLocalFirstClient } from "../local-first-client"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { CanvasInteractionController } from '../../../shared/canvas-interaction'; interface FlowSummary { @@ -97,6 +98,7 @@ class FolkFlowsApp extends HTMLElement { private canvasPanX = 0; private canvasPanY = 0; private selectedNodeId: string | null = null; + private interactionController: CanvasInteractionController | null = null; private draggingNodeId: string | null = null; private dragStartX = 0; private dragStartY = 0; @@ -397,6 +399,8 @@ class FolkFlowsApp extends HTMLElement { } this._mutationObserver?.disconnect(); this._mutationObserver = null; + this.interactionController?.destroy(); + this.interactionController = null; } // ─── Auto-save (debounced) ────────────────────────────── @@ -1275,28 +1279,14 @@ class FolkFlowsApp extends HTMLElement { const svg = this.shadow.getElementById("flow-canvas"); if (!svg) return; - // Wheel: pan (default) or zoom (Ctrl/pinch) - // Trackpad two-finger scroll → pan; trackpad pinch / Ctrl+scroll → zoom - svg.addEventListener("wheel", (e: WheelEvent) => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - // Zoom — ctrlKey is set by trackpad pinch gestures and Ctrl+scroll - const zoomFactor = 1 - e.deltaY * 0.003; - const rect = svg.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * zoomFactor)); - // Zoom toward pointer - this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom); - this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom); - this.canvasZoom = newZoom; - } else { - // Pan — two-finger trackpad scroll or mouse wheel - this.canvasPanX -= e.deltaX; - this.canvasPanY -= e.deltaY; - } - this.updateCanvasTransform(); - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), + setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, + onChange: () => this.updateCanvasTransform(), + }); // Delegated funnel valve + height drag handles svg.addEventListener("pointerdown", (e: PointerEvent) => { diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index f4b48eff..e8537889 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -1030,7 +1030,7 @@ routes.get("/api/flows/board-tasks", async (c) => { const flowsScripts = ` - + `; const mortgageScripts = ` diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts index e985bc10..3d039445 100644 --- a/modules/rgov/components/folk-gov-circuit.ts +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -13,6 +13,8 @@ * circuit — which demo circuit to show (default: all) */ +import { CanvasInteractionController } from '../../../shared/canvas-interaction'; + // ── Types ── interface GovNodeDef { @@ -835,6 +837,7 @@ export class FolkGovCircuit extends HTMLElement { private canvasPanX = 0; private canvasPanY = 0; private showGrid = true; + private interactionController: CanvasInteractionController | null = null; // Sidebar state private paletteOpen = false; @@ -895,6 +898,8 @@ export class FolkGovCircuit extends HTMLElement { if (this._boundTouchStart) document.removeEventListener('touchstart', this._boundTouchStart); if (this._boundTouchMove) document.removeEventListener('touchmove', this._boundTouchMove); if (this._boundTouchEnd) document.removeEventListener('touchend', this._boundTouchEnd); + this.interactionController?.destroy(); + this.interactionController = null; } // ── Data init ── @@ -1569,19 +1574,14 @@ export class FolkGovCircuit extends HTMLElement { }); this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - // Canvas wheel zoom/pan - canvas.addEventListener('wheel', (e: WheelEvent) => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - const rect = svg.getBoundingClientRect(); - const factor = e.deltaY < 0 ? 1.1 : 0.9; - this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); - } else { - this.canvasPanX -= e.deltaX; - this.canvasPanY -= e.deltaY; - this.updateCanvasTransform(); - } - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), + setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, + onChange: () => this.updateCanvasTransform(), + }); // Palette drag palette.querySelectorAll('.gc-palette__card').forEach(card => { diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 0f7f4456..0682943a 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -29,7 +29,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/rminders/components/folk-automation-canvas.ts b/modules/rminders/components/folk-automation-canvas.ts index 75ec9128..8844d6bc 100644 --- a/modules/rminders/components/folk-automation-canvas.ts +++ b/modules/rminders/components/folk-automation-canvas.ts @@ -10,6 +10,7 @@ import { NODE_CATALOG } from '../schemas'; import type { AutomationNodeDef, AutomationNodeCategory, WorkflowNode, WorkflowEdge, Workflow } from '../schemas'; +import { CanvasInteractionController } from '../../../shared/canvas-interaction'; // ── Constants ── @@ -84,6 +85,7 @@ class FolkAutomationCanvas extends HTMLElement { private canvasZoom = 1; private canvasPanX = 0; private canvasPanY = 0; + private interactionController: CanvasInteractionController | null = null; // Interaction private isPanning = false; @@ -136,6 +138,8 @@ class FolkAutomationCanvas extends HTMLElement { if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + this.interactionController?.destroy(); + this.interactionController = null; } // ── Data init ── @@ -786,13 +790,14 @@ class FolkAutomationCanvas extends HTMLElement { }); this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - // Canvas mouse wheel - canvas.addEventListener('wheel', (e: WheelEvent) => { - e.preventDefault(); - const rect = svg.getBoundingClientRect(); - const factor = e.deltaY < 0 ? 1.1 : 0.9; - this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), + setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, + onChange: () => this.updateCanvasTransform(), + }); // Palette drag palette.querySelectorAll('.ac-palette__card').forEach(card => { diff --git a/modules/rminders/mod.ts b/modules/rminders/mod.ts index 3f3d5ee8..afd4e883 100644 --- a/modules/rminders/mod.ts +++ b/modules/rminders/mod.ts @@ -1384,7 +1384,7 @@ routes.get("/reminders", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, }), ); diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index d7a55207..677c664a 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -71,6 +71,7 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import type { DocumentId } from "../../../shared/local-first/document"; import { networkSchema, networkDocId } from "../schemas"; import { ViewHistory } from "../../../shared/view-history.js"; +import { CanvasInteractionController } from "../../../shared/canvas-interaction"; class FolkCrmView extends HTMLElement { private shadow: ShadowRoot; @@ -95,6 +96,7 @@ class FolkCrmView extends HTMLElement { private graphZoom = 1; private graphPanX = 0; private graphPanY = 0; + private graphInteractionController: CanvasInteractionController | null = null; private graphDraggingId: string | null = null; private graphDragStartX = 0; private graphDragStartY = 0; @@ -181,6 +183,8 @@ class FolkCrmView extends HTMLElement { for (const id of this._subscribedDocIds) runtime.unsubscribe(id); } this._subscribedDocIds = []; + this.graphInteractionController?.destroy(); + this.graphInteractionController = null; } private _onViewRestored = (e: CustomEvent) => { @@ -954,12 +958,14 @@ class FolkCrmView extends HTMLElement { }); this.shadow.getElementById("graph-zoom-fit")?.addEventListener("click", () => this.fitGraphView()); - // Wheel zoom - svg.addEventListener("wheel", (e: WheelEvent) => { - e.preventDefault(); - const rect = svg.getBoundingClientRect(); - this.zoomGraphAt(e.clientX - rect.left, e.clientY - rect.top, 1 - e.deltaY * 0.003); - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.graphInteractionController?.destroy(); + this.graphInteractionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.graphPanX, y: this.graphPanY, zoom: this.graphZoom }), + setViewport: (v) => { this.graphPanX = v.x; this.graphPanY = v.y; this.graphZoom = v.zoom; }, + onChange: () => this.updateGraphTransform(), + }); // Pointer down — node drag or canvas pan svg.addEventListener("pointerdown", (e: PointerEvent) => { diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 4b6f9203..6a8ed878 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -720,7 +720,7 @@ function renderCrm(space: string, activeTab: string, isSubdomain: boolean) { spaceSlug: space, modules: getModuleInfoList(), body: ``, - scripts: ` + scripts: ` `, styles: ``, diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 1e4bf0bd..43f1657a 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -28,6 +28,7 @@ import type { import { SocialsLocalFirstClient } from '../local-first-client'; import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { CanvasInteractionController } from '../../../shared/canvas-interaction'; import { PLATFORM_SPECS, ALL_PLATFORM_IDS, @@ -173,6 +174,7 @@ class FolkCampaignPlanner extends HTMLElement { private canvasZoom = 1; private canvasPanX = 0; private canvasPanY = 0; + private interactionController: CanvasInteractionController | null = null; // Interaction state private isPanning = false; @@ -265,6 +267,8 @@ class FolkCampaignPlanner extends HTMLElement { if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + this.interactionController?.destroy(); + this.interactionController = null; this.localFirstClient?.disconnect(); } @@ -2647,21 +2651,14 @@ class FolkCampaignPlanner extends HTMLElement { }); this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - // Wheel: two-finger scroll = pan, Ctrl/Cmd+wheel or trackpad pinch (ctrlKey) = zoom - svg.addEventListener('wheel', (e: WheelEvent) => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - const rect = svg.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const factor = 1 - e.deltaY * 0.01; - this.zoomAt(mx, my, factor); - } else { - this.canvasPanX -= e.deltaX; - this.canvasPanY -= e.deltaY; - this.updateCanvasTransform(); - } - }, { passive: false }); + // Shared interaction controller — wheel=pan, Ctrl+wheel/pinch=zoom, two-finger touch=pan+pinch + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), + setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, + onChange: () => this.updateCanvasTransform(), + }); // Context menu svg.addEventListener('contextmenu', (e: MouseEvent) => { diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts index 7e9c144c..b6dbd8fd 100644 --- a/modules/rsocials/components/folk-campaign-workflow.ts +++ b/modules/rsocials/components/folk-campaign-workflow.ts @@ -10,6 +10,7 @@ */ import { CAMPAIGN_NODE_CATALOG, socialsDocId } from '../schemas'; +import { CanvasInteractionController } from '../../../shared/canvas-interaction'; import type { CampaignWorkflowNodeDef, CampaignWorkflowNodeCategory, @@ -95,6 +96,7 @@ class FolkCampaignWorkflow extends HTMLElement { private canvasZoom = 1; private canvasPanX = 0; private canvasPanY = 0; + private interactionController: CanvasInteractionController | null = null; // Interaction private isPanning = false; @@ -154,6 +156,8 @@ class FolkCampaignWorkflow extends HTMLElement { if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + this.interactionController?.destroy(); + this.interactionController = null; } // ── Data init ── @@ -821,21 +825,14 @@ class FolkCampaignWorkflow extends HTMLElement { }); this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - // Canvas wheel: two-finger trackpad = pan, Ctrl+wheel / pinch = zoom - canvas.addEventListener('wheel', (e: WheelEvent) => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - // Pinch-to-zoom or Ctrl+scroll - const rect = svg.getBoundingClientRect(); - const factor = e.deltaY < 0 ? 1.1 : 0.9; - this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); - } else { - // Two-finger swipe = pan - this.canvasPanX -= e.deltaX; - this.canvasPanY -= e.deltaY; - this.updateCanvasTransform(); - } - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: svg, + getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), + setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, + onChange: () => this.updateCanvasTransform(), + }); // Palette drag palette.querySelectorAll('.cw-palette__card').forEach(card => { diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index e3bb3767..6e5a6209 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -2886,7 +2886,7 @@ routes.get("/campaign-flow", (c) => { theme: "dark", body: ``, styles: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 45304fba..5afea781 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -9,6 +9,7 @@ import type { DocumentId } from "../../../shared/local-first/document"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { commitmentsSchema, commitmentsDocId } from "../schemas"; import { ViewHistory } from "../../../shared/view-history.js"; +import { CanvasInteractionController } from "../../../shared/canvas-interaction"; // ── Constants ── @@ -301,6 +302,7 @@ class FolkTimebankApp extends HTMLElement { private panStart = { x: 0, y: 0, panX: 0, panY: 0 }; private spaceHeld = false; private intentFramesLayer!: SVGGElement; + private interactionController: CanvasInteractionController | null = null; // Orb drag-to-canvas state private draggingOrb: Orb | null = null; @@ -458,6 +460,8 @@ class FolkTimebankApp extends HTMLElement { this._subscribedDocIds = []; this._resizeObserver?.disconnect(); this._resizeObserver = null; + this.interactionController?.destroy(); + this.interactionController = null; } private _onViewRestored = (e: CustomEvent) => { @@ -1308,27 +1312,15 @@ class FolkTimebankApp extends HTMLElement { if (taskNode) this.openTaskEditor(taskNode); }); - // Pan/zoom: wheel - wrap.addEventListener('wheel', (e) => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - // Zoom centered on cursor - const rect = wrap.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const delta = -e.deltaY * 0.002; - const newScale = Math.max(0.1, Math.min(5, this.scale * (1 + delta))); - const ratio = newScale / this.scale; - this.panX = mx - ratio * (mx - this.panX); - this.panY = my - ratio * (my - this.panY); - this.scale = newScale; - } else { - // Regular wheel = pan - this.panX -= e.deltaX; - this.panY -= e.deltaY; - } - this.updateCanvasTransform(); - }, { passive: false }); + // Shared interaction controller — parity with main rSpace canvas + this.interactionController?.destroy(); + this.interactionController = new CanvasInteractionController({ + target: wrap, + 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.updateCanvasTransform(), + maxZoom: 5, + }); // Space+drag pan const keydown = (e: KeyboardEvent) => { diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 3216bdf9..e79da3cb 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -1125,7 +1125,7 @@ function renderTimePage(space: string, view: string, activeTab: string, isSubdom modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, tabs: [...RTIME_TABS], activeTab, diff --git a/shared/canvas-interaction.ts b/shared/canvas-interaction.ts new file mode 100644 index 00000000..67bd7e6c --- /dev/null +++ b/shared/canvas-interaction.ts @@ -0,0 +1,210 @@ +/** + * 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); +}