rspace-online/lib/folk-group-frame.ts

198 lines
5.4 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.

/**
* <folk-group-frame> — Visual overlay for shape groups.
* Renders a dashed border + header bar positioned over the bounding box
* of grouped shapes. Not a FolkShape — it's a lightweight overlay element.
*/
const PADDING = 16;
const HEADER_HEIGHT = 28;
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
position: absolute;
pointer-events: none;
z-index: 1;
}
.frame {
position: relative;
width: 100%;
height: 100%;
border: 2px dashed var(--group-color, #14b8a6);
border-radius: 12px;
box-sizing: border-box;
}
.header {
position: absolute;
top: -${HEADER_HEIGHT + 4}px;
left: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
background: var(--group-color, #14b8a6);
color: white;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
pointer-events: auto;
cursor: pointer;
user-select: none;
white-space: nowrap;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
.header .icon { font-size: 13px; }
.header .name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.header .count {
opacity: 0.8;
font-weight: 400;
font-size: 10px;
}
.header .actions {
display: flex;
gap: 2px;
}
.header .actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
line-height: 1;
}
.header .actions button:hover {
background: rgba(255,255,255,0.25);
}
/* Collapsed summary card */
.collapsed-summary {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 20px;
background: var(--group-color, #14b8a6);
color: white;
border-radius: 10px;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
pointer-events: auto;
cursor: pointer;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
:host([collapsed]) .collapsed-summary {
display: block;
}
:host([collapsed]) .frame {
border-style: solid;
background: rgba(0,0,0,0.03);
}
</style>
<div class="frame">
<div class="header">
<span class="icon"></span>
<span class="name"></span>
<span class="count"></span>
<span class="actions">
<button class="collapse-btn" title="Collapse/Expand"></button>
<button class="dissolve-btn" title="Dissolve group">×</button>
</span>
</div>
<div class="collapsed-summary"></div>
</div>
`;
export class FolkGroupFrame extends HTMLElement {
#groupId = "";
#name = "";
#icon = "";
#color = "#14b8a6";
#memberCount = 0;
#collapsed = false;
static get observedAttributes() {
return ["group-id", "group-name", "group-icon", "group-color", "member-count", "collapsed"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot!.appendChild(template.content.cloneNode(true));
this.shadowRoot!.querySelector(".collapse-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("group-toggle-collapse", {
detail: { groupId: this.#groupId },
bubbles: true, composed: true,
}));
});
this.shadowRoot!.querySelector(".dissolve-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("group-dissolve", {
detail: { groupId: this.#groupId },
bubbles: true, composed: true,
}));
});
this.shadowRoot!.querySelector(".collapsed-summary")!.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("group-toggle-collapse", {
detail: { groupId: this.#groupId },
bubbles: true, composed: true,
}));
});
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
switch (name) {
case "group-id": this.#groupId = val || ""; break;
case "group-name": this.#name = val || ""; break;
case "group-icon": this.#icon = val || ""; break;
case "group-color":
this.#color = val || "#14b8a6";
this.style.setProperty("--group-color", this.#color);
break;
case "member-count": this.#memberCount = Number(val) || 0; break;
case "collapsed":
this.#collapsed = val !== null;
break;
}
this.#render();
}
/** Position the frame over a bounding box (canvas coords). */
setBounds(x: number, y: number, width: number, height: number) {
this.style.left = `${x - PADDING}px`;
this.style.top = `${y - PADDING - HEADER_HEIGHT}px`;
this.style.width = `${width + PADDING * 2}px`;
this.style.height = `${height + PADDING * 2 + HEADER_HEIGHT}px`;
}
#render() {
const icon = this.shadowRoot!.querySelector(".icon") as HTMLElement;
const name = this.shadowRoot!.querySelector(".name") as HTMLElement;
const count = this.shadowRoot!.querySelector(".count") as HTMLElement;
const collapseBtn = this.shadowRoot!.querySelector(".collapse-btn") as HTMLElement;
const summary = this.shadowRoot!.querySelector(".collapsed-summary") as HTMLElement;
icon.textContent = this.#icon;
name.textContent = this.#name;
count.textContent = `(${this.#memberCount})`;
collapseBtn.textContent = this.#collapsed ? "+" : "";
summary.textContent = `${this.#icon} ${this.#name}${this.#memberCount} shapes`;
}
get groupId() { return this.#groupId; }
}
if (!customElements.get("folk-group-frame")) {
customElements.define("folk-group-frame", FolkGroupFrame);
}