/** * — 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 };