151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
/**
|
||
* <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 };
|