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);
+}