merge(dev): Phase A.5 canvas interaction parity
CI/CD / deploy (push) Successful in 2m56s
Details
CI/CD / deploy (push) Successful in 2m56s
Details
This commit is contained in:
commit
4eba2cb163
|
|
@ -268,6 +268,8 @@ export class AppletCircuitCanvas extends HTMLElement {
|
|||
onChange: () => this.#updateTransform(),
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3,
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.#fitView() },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1286,6 +1286,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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(),
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
});
|
||||
|
||||
// Delegated funnel valve + height drag handles
|
||||
|
|
|
|||
|
|
@ -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=6"></script>
|
||||
<script type="module" src="/modules/rflows/folk-flows-app.js?v=7"></script>
|
||||
<script type="module" src="/modules/rflows/folk-flow-river.js?v=4"></script>`;
|
||||
|
||||
const mortgageScripts = `
|
||||
|
|
|
|||
|
|
@ -1581,6 +1581,8 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
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(),
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
});
|
||||
|
||||
// Palette drag
|
||||
|
|
|
|||
|
|
@ -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?v=1"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rgov/folk-gov-circuit.js?v=2"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -797,6 +797,8 @@ class FolkAutomationCanvas extends HTMLElement {
|
|||
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(),
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
});
|
||||
|
||||
// Palette drag
|
||||
|
|
|
|||
|
|
@ -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?v=1"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rminders/folk-automation-canvas.js?v=2"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rminders/automation-canvas.css">`,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -965,6 +965,8 @@ class FolkCrmView extends HTMLElement {
|
|||
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(),
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitGraphView() },
|
||||
});
|
||||
|
||||
// Pointer down — node drag or canvas pan
|
||||
|
|
|
|||
|
|
@ -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=4"></script>
|
||||
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js?v=5"></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">`,
|
||||
|
|
|
|||
|
|
@ -2960,6 +2960,8 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
if (!t) return true;
|
||||
return !t.closest('.cp-inline-config, .inline-edit-overlay');
|
||||
},
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
});
|
||||
|
||||
// Context menu
|
||||
|
|
|
|||
|
|
@ -832,6 +832,8 @@ class FolkCampaignWorkflow extends HTMLElement {
|
|||
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(),
|
||||
enableSpaceToGrab: true,
|
||||
enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
});
|
||||
|
||||
// Palette drag
|
||||
|
|
|
|||
|
|
@ -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=5">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=6"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=7"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,9 @@ class FolkTimebankApp extends HTMLElement {
|
|||
if (taskNode) this.openTaskEditor(taskNode);
|
||||
});
|
||||
|
||||
// Shared interaction controller — parity with main rSpace canvas
|
||||
// Shared interaction controller — parity with main rSpace canvas.
|
||||
// rTime has its own Space tracking integrated with node drag state, so we
|
||||
// don't opt into enableSpaceToGrab here to avoid double cursor handling.
|
||||
this.interactionController?.destroy();
|
||||
this.interactionController = new CanvasInteractionController({
|
||||
target: wrap,
|
||||
|
|
|
|||
|
|
@ -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=3"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=4"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
|
||||
tabs: [...RTIME_TABS],
|
||||
activeTab,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,24 @@
|
|||
*
|
||||
* 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
|
||||
* • Wheel / two-finger scroll → PAN
|
||||
* • Ctrl/Cmd+wheel or pinch → ZOOM at cursor
|
||||
* • Two-finger touch → PAN + pinch-zoom at gesture center
|
||||
* • Space (opt-in) → grab cursor; rApp drives pointer pan
|
||||
* • Keyboard shortcuts (opt-in) → 0 fit, +/- zoom, arrows pan
|
||||
*
|
||||
* 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.
|
||||
* Pointer-based pan and marquee selection remain owned by each rApp
|
||||
* because they depend on per-rApp hit-testing and selection state —
|
||||
* but the controller exposes `isSpaceHeld()` so rApps can branch on it.
|
||||
*
|
||||
* Usage:
|
||||
* const controller = new CanvasInteractionController({
|
||||
* target: svgEl, // element that receives wheel/touch events
|
||||
* target: svgEl,
|
||||
* 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(),
|
||||
* enableKeyboardShortcuts: { fit: () => this.fitView() },
|
||||
* enableSpaceToGrab: true,
|
||||
* });
|
||||
* // ...on teardown:
|
||||
* controller.destroy();
|
||||
|
|
@ -28,6 +32,17 @@ export interface Viewport {
|
|||
zoom: number;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
/** Called on `0`. rApp fits all nodes into view. */
|
||||
fit?: () => void;
|
||||
/** Called on `Ctrl/Cmd+Z`. */
|
||||
undo?: () => void;
|
||||
/** Called on `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`. */
|
||||
redo?: () => void;
|
||||
/** Called on `Delete`/`Backspace` when target is focused. */
|
||||
deleteSelected?: () => void;
|
||||
}
|
||||
|
||||
export interface CanvasInteractionOptions {
|
||||
/** Element that wheel + touch events attach to. */
|
||||
target: HTMLElement | SVGElement;
|
||||
|
|
@ -46,9 +61,25 @@ export interface CanvasInteractionOptions {
|
|||
* 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;
|
||||
isEnabled?(e: WheelEvent | TouchEvent | KeyboardEvent): boolean;
|
||||
/**
|
||||
* Opt-in: listen for Space on `document` and flip the target cursor
|
||||
* to `grab`. The rApp's own pointerdown handler should check
|
||||
* `controller.isSpaceHeld()` to decide whether to enter pan mode.
|
||||
*/
|
||||
enableSpaceToGrab?: boolean;
|
||||
/**
|
||||
* Opt-in: keyboard shortcuts for fit, undo/redo, delete, and zoom
|
||||
* (`0`, `Ctrl+Z`, `Ctrl+Shift+Z`, `Delete`, `+`, `-`, arrow keys).
|
||||
* Arrow keys pan the viewport by `ARROW_PAN_STEP` px. `+`/`-` zoom
|
||||
* around the viewport center.
|
||||
*/
|
||||
enableKeyboardShortcuts?: KeyboardShortcutHandlers | boolean;
|
||||
}
|
||||
|
||||
const ARROW_PAN_STEP = 40;
|
||||
const BUTTON_ZOOM_FACTOR = 1.25;
|
||||
|
||||
export class CanvasInteractionController {
|
||||
private target: HTMLElement | SVGElement;
|
||||
private opts: CanvasInteractionOptions;
|
||||
|
|
@ -61,11 +92,17 @@ export class CanvasInteractionController {
|
|||
private lastTouchDist: number | null = null;
|
||||
private isTouchGesture = false;
|
||||
|
||||
// Space-to-grab state
|
||||
private spaceHeld = false;
|
||||
private previousCursor = "";
|
||||
|
||||
// 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;
|
||||
private readonly onKeyDown: ((e: KeyboardEvent) => void) | null;
|
||||
private readonly onKeyUp: ((e: KeyboardEvent) => void) | null;
|
||||
|
||||
constructor(opts: CanvasInteractionOptions) {
|
||||
this.target = opts.target;
|
||||
|
|
@ -83,6 +120,19 @@ export class CanvasInteractionController {
|
|||
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);
|
||||
|
||||
// Keyboard is on `document` because targets often lack focus.
|
||||
const wantKeys = !!opts.enableKeyboardShortcuts;
|
||||
const wantSpace = !!opts.enableSpaceToGrab;
|
||||
if (wantKeys || wantSpace) {
|
||||
this.onKeyDown = this.handleKeyDown.bind(this);
|
||||
this.onKeyUp = this.handleKeyUp.bind(this);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
document.addEventListener("keyup", this.onKeyUp);
|
||||
} else {
|
||||
this.onKeyDown = null;
|
||||
this.onKeyUp = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach all event listeners. */
|
||||
|
|
@ -91,6 +141,12 @@ export class CanvasInteractionController {
|
|||
this.target.removeEventListener("touchstart", this.onTouchStart as EventListener);
|
||||
this.target.removeEventListener("touchmove", this.onTouchMove as EventListener);
|
||||
this.target.removeEventListener("touchend", this.onTouchEnd as EventListener);
|
||||
if (this.onKeyDown) document.removeEventListener("keydown", this.onKeyDown);
|
||||
if (this.onKeyUp) document.removeEventListener("keyup", this.onKeyUp);
|
||||
// Restore cursor if we still had it set
|
||||
if (this.spaceHeld) {
|
||||
(this.target as HTMLElement).style.cursor = this.previousCursor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,6 +174,17 @@ export class CanvasInteractionController {
|
|||
this.opts.onChange?.();
|
||||
}
|
||||
|
||||
/** Zoom centered on the target element. Used by chrome +/- buttons. */
|
||||
zoomByFactor(factor: number): void {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
this.zoomAt(rect.width / 2, rect.height / 2, factor);
|
||||
}
|
||||
|
||||
/** Is Space currently held down? rApps can check this in pointerdown. */
|
||||
isSpaceHeld(): boolean {
|
||||
return this.spaceHeld;
|
||||
}
|
||||
|
||||
private clampZoom(z: number): number {
|
||||
return Math.min(this.maxZoom, Math.max(this.minZoom, z));
|
||||
}
|
||||
|
|
@ -194,6 +261,76 @@ export class CanvasInteractionController {
|
|||
this.lastTouchDist = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyDown(e: KeyboardEvent): void {
|
||||
// Bail on text inputs (rApps often have inline editors).
|
||||
if (isInTextInput(e)) return;
|
||||
if (this.opts.isEnabled && !this.opts.isEnabled(e)) return;
|
||||
|
||||
// Space-to-grab (opt-in).
|
||||
if (this.opts.enableSpaceToGrab && e.code === "Space" && !this.spaceHeld) {
|
||||
e.preventDefault();
|
||||
this.spaceHeld = true;
|
||||
this.previousCursor = (this.target as HTMLElement).style.cursor || "";
|
||||
(this.target as HTMLElement).style.cursor = "grab";
|
||||
}
|
||||
|
||||
const ks = this.opts.enableKeyboardShortcuts;
|
||||
if (!ks) return;
|
||||
const handlers: KeyboardShortcutHandlers = ks === true ? {} : ks;
|
||||
|
||||
// Fit view: `0`
|
||||
if (e.key === "0" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
handlers.fit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom: `+` / `=` / `-`
|
||||
if ((e.key === "+" || e.key === "=") && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.zoomByFactor(BUTTON_ZOOM_FACTOR);
|
||||
return;
|
||||
}
|
||||
if (e.key === "-" && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.zoomByFactor(1 / BUTTON_ZOOM_FACTOR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow pan
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); this.panBy(ARROW_PAN_STEP, 0); return; }
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); this.panBy(-ARROW_PAN_STEP, 0); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); this.panBy(0, ARROW_PAN_STEP); return; }
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); this.panBy(0, -ARROW_PAN_STEP); return; }
|
||||
|
||||
// Undo / Redo
|
||||
if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) handlers.redo?.();
|
||||
else handlers.undo?.();
|
||||
return;
|
||||
}
|
||||
if ((e.key === "y" || e.key === "Y") && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handlers.redo?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete selected
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && handlers.deleteSelected) {
|
||||
e.preventDefault();
|
||||
handlers.deleteSelected();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyUp(e: KeyboardEvent): void {
|
||||
if (e.code === "Space" && this.spaceHeld) {
|
||||
this.spaceHeld = false;
|
||||
(this.target as HTMLElement).style.cursor = this.previousCursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touchCenter(ts: TouchList): { x: number; y: number } {
|
||||
|
|
@ -208,3 +345,13 @@ function touchDist(ts: TouchList): number {
|
|||
const dy = ts[0].clientY - ts[1].clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadow-DOM-aware text input detector. Keyboard events are retargeted
|
||||
* to the shadow host, so we walk `composedPath()` for true targets.
|
||||
*/
|
||||
function isInTextInput(e: KeyboardEvent): boolean {
|
||||
return e.composedPath().some(el =>
|
||||
el instanceof HTMLElement && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Canvas viewport helpers — shared across all rApp mini-canvases.
|
||||
*
|
||||
* Provides:
|
||||
* • `fitViewToNodes()` — identical fit-to-content algorithm
|
||||
* • `persistViewport()` / `restoreViewport()` — localStorage I/O
|
||||
*
|
||||
* Matches the behavior of the main rSpace canvas in `website/canvas.html`.
|
||||
*/
|
||||
|
||||
import type { Viewport } from "./canvas-interaction";
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FitViewOptions {
|
||||
/** Padding in screen pixels around the content. Default 40. */
|
||||
padding?: number;
|
||||
/** Maximum zoom after fit. Default 1.5. */
|
||||
maxZoom?: number;
|
||||
/** Minimum zoom after fit. Default 0.1. */
|
||||
minZoom?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the viewport that fits the given content rects inside `target`.
|
||||
* Returns `null` if there are no rects or the target is zero-sized.
|
||||
*
|
||||
* `rects` are in canvas (content) coordinates.
|
||||
*/
|
||||
export function fitViewToRects(
|
||||
rects: Rect[],
|
||||
target: Element,
|
||||
options: FitViewOptions = {},
|
||||
): Viewport | null {
|
||||
if (rects.length === 0) return null;
|
||||
const bounding = target.getBoundingClientRect();
|
||||
if (bounding.width === 0 || bounding.height === 0) return null;
|
||||
|
||||
const pad = options.padding ?? 40;
|
||||
const maxZoom = options.maxZoom ?? 1.5;
|
||||
const minZoom = options.minZoom ?? 0.1;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const r of rects) {
|
||||
if (r.x < minX) minX = r.x;
|
||||
if (r.y < minY) minY = r.y;
|
||||
if (r.x + r.width > maxX) maxX = r.x + r.width;
|
||||
if (r.y + r.height > maxY) maxY = r.y + r.height;
|
||||
}
|
||||
|
||||
const contentW = (maxX - minX) + pad * 2;
|
||||
const contentH = (maxY - minY) + pad * 2;
|
||||
const scaleX = bounding.width / contentW;
|
||||
const scaleY = bounding.height / contentH;
|
||||
const zoom = clamp(Math.min(scaleX, scaleY), minZoom, maxZoom);
|
||||
|
||||
const x = (bounding.width - contentW * zoom) / 2 - (minX - pad) * zoom;
|
||||
const y = (bounding.height - contentH * zoom) / 2 - (minY - pad) * zoom;
|
||||
|
||||
return { x, y, zoom };
|
||||
}
|
||||
|
||||
/** Convenience wrapper: extract rects from typed nodes with `position` + size. */
|
||||
export function fitViewToNodes<N extends { position: { x: number; y: number } }>(
|
||||
nodes: N[],
|
||||
getSize: (n: N) => { w: number; h: number },
|
||||
target: Element,
|
||||
options?: FitViewOptions,
|
||||
): Viewport | null {
|
||||
return fitViewToRects(
|
||||
nodes.map(n => {
|
||||
const s = getSize(n);
|
||||
return { x: n.position.x, y: n.position.y, width: s.w, height: s.h };
|
||||
}),
|
||||
target,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const VIEWPORT_KEY_PREFIX = "rspace_viewport:";
|
||||
|
||||
/** Persist viewport to localStorage under a scoped key. */
|
||||
export function persistViewport(key: string, v: Viewport): void {
|
||||
try {
|
||||
localStorage.setItem(VIEWPORT_KEY_PREFIX + key, JSON.stringify({
|
||||
x: v.x,
|
||||
y: v.y,
|
||||
zoom: v.zoom,
|
||||
}));
|
||||
} catch {
|
||||
/* quota exceeded or storage disabled — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a previously persisted viewport. Returns null if none stored or invalid. */
|
||||
export function restoreViewport(key: string): Viewport | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(VIEWPORT_KEY_PREFIX + key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Viewport>;
|
||||
if (typeof parsed.x !== "number" || typeof parsed.y !== "number" || typeof parsed.zoom !== "number") return null;
|
||||
if (!isFinite(parsed.x) || !isFinite(parsed.y) || !isFinite(parsed.zoom)) return null;
|
||||
return { x: parsed.x, y: parsed.y, zoom: parsed.zoom };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* <rspace-canvas-chrome> — shared zoom/fit UI for rApp mini-canvases.
|
||||
*
|
||||
* Matches the main rSpace canvas chrome: zoom-out, zoom-in, fit-view
|
||||
* buttons with an optional percentage indicator. Optional grid toggle.
|
||||
*
|
||||
* Events:
|
||||
* • `canvas-zoom-in` — user clicked the `+` button
|
||||
* • `canvas-zoom-out` — user clicked the `−` button
|
||||
* • `canvas-zoom-fit` — user clicked the `⊡` button
|
||||
* • `canvas-grid-toggle` — user toggled the grid (detail: { on: boolean })
|
||||
*
|
||||
* Attributes:
|
||||
* • `zoom` — number, the current zoom to display as a percentage
|
||||
* • `position` — "top-right" | "bottom-right" | "bottom-left" | "inline" (default "bottom-right")
|
||||
* • `show-grid-toggle` — presence = render grid toggle button
|
||||
*
|
||||
* Consumers should listen for the events on the chrome element and
|
||||
* call the controller's `zoomByFactor()` / `fitView()` handlers.
|
||||
*/
|
||||
|
||||
const styles = `
|
||||
:host {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: rgba(30, 41, 59, 0.92);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
:host([position="top-right"]) { top: 12px; right: 12px; }
|
||||
:host([position="bottom-right"]) { bottom: 12px; right: 12px; }
|
||||
:host([position="bottom-left"]) { bottom: 12px; left: 12px; }
|
||||
:host([position="inline"]) { position: static; display: inline-flex; }
|
||||
button {
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
button:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
button:active { background: rgba(255, 255, 255, 0.18); }
|
||||
button[aria-pressed="true"] { background: rgba(20, 184, 166, 0.3); color: #5eead4; }
|
||||
.zoom-level {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sep {
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
class RSpaceCanvasChrome extends HTMLElement {
|
||||
static get observedAttributes() { return ["zoom", "show-grid-toggle"]; }
|
||||
|
||||
private shadow: ShadowRoot;
|
||||
private zoomLabel: HTMLSpanElement | null = null;
|
||||
private gridOn = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.hasAttribute("position")) this.setAttribute("position", "bottom-right");
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string) {
|
||||
if (name === "zoom") this.updateZoomLabel();
|
||||
if (name === "show-grid-toggle") this.render();
|
||||
}
|
||||
|
||||
/** Imperative setter as an alternative to the `zoom` attribute. */
|
||||
setZoom(z: number): void {
|
||||
this.setAttribute("zoom", String(z));
|
||||
}
|
||||
|
||||
private render() {
|
||||
const showGrid = this.hasAttribute("show-grid-toggle");
|
||||
this.shadow.innerHTML = `
|
||||
<style>${styles}</style>
|
||||
<button type="button" data-action="zoom-out" aria-label="Zoom out" title="Zoom out">−</button>
|
||||
<span class="zoom-level" id="zoom-level">100%</span>
|
||||
<button type="button" data-action="zoom-in" aria-label="Zoom in" title="Zoom in">+</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" data-action="fit" aria-label="Fit to view" title="Fit (0)">⊡</button>
|
||||
${showGrid ? `<button type="button" data-action="grid" aria-label="Toggle grid" aria-pressed="${this.gridOn ? "true" : "false"}" title="Toggle grid">⋮⋮</button>` : ""}
|
||||
`;
|
||||
this.zoomLabel = this.shadow.getElementById("zoom-level") as HTMLSpanElement;
|
||||
this.updateZoomLabel();
|
||||
this.shadow.addEventListener("click", (e) => this.handleClick(e as MouseEvent));
|
||||
}
|
||||
|
||||
private handleClick(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest("button[data-action]") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
switch (action) {
|
||||
case "zoom-in": this.dispatchEvent(new CustomEvent("canvas-zoom-in", { bubbles: true, composed: true })); break;
|
||||
case "zoom-out": this.dispatchEvent(new CustomEvent("canvas-zoom-out", { bubbles: true, composed: true })); break;
|
||||
case "fit": this.dispatchEvent(new CustomEvent("canvas-zoom-fit", { bubbles: true, composed: true })); break;
|
||||
case "grid":
|
||||
this.gridOn = !this.gridOn;
|
||||
btn.setAttribute("aria-pressed", this.gridOn ? "true" : "false");
|
||||
this.dispatchEvent(new CustomEvent("canvas-grid-toggle", { bubbles: true, composed: true, detail: { on: this.gridOn } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private updateZoomLabel() {
|
||||
if (!this.zoomLabel) return;
|
||||
const z = parseFloat(this.getAttribute("zoom") || "1");
|
||||
const pct = isFinite(z) ? Math.round(z * 100) : 100;
|
||||
this.zoomLabel.textContent = `${pct}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("rspace-canvas-chrome")) {
|
||||
customElements.define("rspace-canvas-chrome", RSpaceCanvasChrome);
|
||||
}
|
||||
|
||||
export { RSpaceCanvasChrome };
|
||||
Loading…
Reference in New Issue