feat(canvas): shared CanvasInteractionController for rApp mini-canvases
CI/CD / deploy (push) Successful in 3m38s
Details
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:
parent
02889ce3ec
commit
d3e65a0522
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue