diff --git a/lib/applet-circuit-canvas.ts b/lib/applet-circuit-canvas.ts
index 18167dae..1f76f483 100644
--- a/lib/applet-circuit-canvas.ts
+++ b/lib/applet-circuit-canvas.ts
@@ -268,6 +268,8 @@ export class AppletCircuitCanvas extends HTMLElement {
onChange: () => this.#updateTransform(),
minZoom: 0.2,
maxZoom: 3,
+ enableSpaceToGrab: true,
+ enableKeyboardShortcuts: { fit: () => this.#fitView() },
});
}
diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts
index ebe9bdab..e624b327 100644
--- a/modules/rflows/components/folk-flows-app.ts
+++ b/modules/rflows/components/folk-flows-app.ts
@@ -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
diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts
index e8537889..382d033e 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 3d039445..0fc1ea01 100644
--- a/modules/rgov/components/folk-gov-circuit.ts
+++ b/modules/rgov/components/folk-gov-circuit.ts
@@ -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
diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts
index 0682943a..9431d47e 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 8844d6bc..82ea9ea6 100644
--- a/modules/rminders/components/folk-automation-canvas.ts
+++ b/modules/rminders/components/folk-automation-canvas.ts
@@ -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
diff --git a/modules/rminders/mod.ts b/modules/rminders/mod.ts
index afd4e883..e1091428 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 677c664a..208c08c1 100644
--- a/modules/rnetwork/components/folk-crm-view.ts
+++ b/modules/rnetwork/components/folk-crm-view.ts
@@ -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
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts
index 6a8ed878..8b293740 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 77bd2f6a..aaefe683 100644
--- a/modules/rsocials/components/folk-campaign-planner.ts
+++ b/modules/rsocials/components/folk-campaign-planner.ts
@@ -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
diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts
index b6dbd8fd..302e6810 100644
--- a/modules/rsocials/components/folk-campaign-workflow.ts
+++ b/modules/rsocials/components/folk-campaign-workflow.ts
@@ -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
diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts
index d9ccd0ed..8365bb2e 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 5afea781..e4042872 100644
--- a/modules/rtime/components/folk-timebank-app.ts
+++ b/modules/rtime/components/folk-timebank-app.ts
@@ -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,
diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts
index e79da3cb..63b8eeed 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
index 67bd7e6c..9009fd57 100644
--- a/shared/canvas-interaction.ts
+++ b/shared/canvas-interaction.ts
@@ -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),
+ );
+}
diff --git a/shared/canvas-viewport.ts b/shared/canvas-viewport.ts
new file mode 100644
index 00000000..f519d3ae
--- /dev/null
+++ b/shared/canvas-viewport.ts
@@ -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(
+ 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;
+ 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));
+}
diff --git a/shared/components/rspace-canvas-chrome.ts b/shared/components/rspace-canvas-chrome.ts
new file mode 100644
index 00000000..18aef28a
--- /dev/null
+++ b/shared/components/rspace-canvas-chrome.ts
@@ -0,0 +1,150 @@
+/**
+ * — 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 = `
+
+
+ 100%
+
+
+
+ ${showGrid ? `` : ""}
+ `;
+ 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 };