rspace-online/shared/components/rspace-canvas-chrome.ts

151 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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