198 lines
5.4 KiB
TypeScript
198 lines
5.4 KiB
TypeScript
/**
|
||
* <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);
|
||
}
|