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