feat(canvas): shared CanvasInteractionController for rApp mini-canvases
CI/CD / deploy (push) Successful in 3m38s Details

Extract the rSpace main canvas wheel + touch interaction model into a
single shared controller (shared/canvas-interaction.ts). Wheel defaults
to pan, Ctrl/Cmd+wheel or trackpad pinch (ctrlKey) zooms at cursor,
two-finger touch pans + pinch-zooms at gesture center.

Migrate 8 rApp mini-canvases to use it, replacing 8 slightly-different
hand-rolled wheel handlers:
- rsocials campaign-planner (the bug report — was zoom-only)
- rsocials campaign-workflow
- rminders automation canvas (was zoom-only)
- rtime timebank weave
- rflows canvas
- rgov circuit
- rnetwork CRM graph (was zoom-only)
- lib/applet-circuit-canvas (was zoom-only)

Fixes the "wheel = zoom" regression everywhere and gives each rApp
canvas two-finger touch pan + pinch for free. Pointer-based pan and
marquee selection remain per-rApp because they depend on per-rApp
hit-testing. Bump asset cache versions for all affected routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 15:07:32 -04:00
parent 02889ce3ec
commit d3e65a0522
15 changed files with 319 additions and 119 deletions

View File

@ -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;
}
}

View File

@ -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) => {

View File

@ -1030,7 +1030,7 @@ routes.get("/api/flows/board-tasks", async (c) => {
const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js?v=5"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js?v=6"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js?v=4"></script>`;
const mortgageScripts = `

View File

@ -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 => {

View File

@ -29,7 +29,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-gov-circuit space="${space}" style="width:100%;height:100%;display:block;"></folk-gov-circuit>`,
scripts: `<script type="module" src="/modules/rgov/folk-gov-circuit.js"></script>`,
scripts: `<script type="module" src="/modules/rgov/folk-gov-circuit.js?v=1"></script>`,
}));
});

View File

@ -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 => {

View File

@ -1384,7 +1384,7 @@ routes.get("/reminders", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-automation-canvas space="${space}"></folk-automation-canvas>`,
scripts: `<script type="module" src="/modules/rminders/folk-automation-canvas.js"></script>`,
scripts: `<script type="module" src="/modules/rminders/folk-automation-canvas.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rminders/automation-canvas.css">`,
}),
);

View File

@ -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) => {

View File

@ -720,7 +720,7 @@ function renderCrm(space: string, activeTab: string, isSubdomain: boolean) {
spaceSlug: space,
modules: getModuleInfoList(),
body: `<folk-crm-view space="${space}"></folk-crm-view>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js?v=3"></script>
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js?v=4"></script>
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js?v=2"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,

View File

@ -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) => {

View File

@ -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 => {

View File

@ -2886,7 +2886,7 @@ routes.get("/campaign-flow", (c) => {
theme: "dark",
body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css?v=4">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=4"></script>`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=5"></script>`,
}));
});

View File

@ -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) => {

View File

@ -1125,7 +1125,7 @@ function renderTimePage(space: string, view: string, activeTab: string, isSubdom
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="${view}"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=2"></script>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
tabs: [...RTIME_TABS],
activeTab,

View File

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