130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { GridStack, type GridStackNode } from 'gridstack';
|
|
|
|
const STORAGE_PREFIX = 'gridstack:';
|
|
|
|
/** Vanilla custom element (not Lit) — its children are Flow-rendered Vaadin
|
|
* components, not something this element renders itself. A Lit render root
|
|
* would fight gridstack for ownership of the light DOM, so this class never
|
|
* touches its own childList except by reading/reacting to it. */
|
|
class GridStackLayout extends HTMLElement {
|
|
private grid?: GridStack;
|
|
private observer?: MutationObserver;
|
|
private storageKey: string | null = null;
|
|
private persistTimer?: ReturnType<typeof setTimeout>;
|
|
|
|
connectedCallback() {
|
|
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
|
|
this.observer.observe(this, { childList: true });
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.observer?.disconnect();
|
|
this.observer = undefined;
|
|
clearTimeout(this.persistTimer);
|
|
this.grid?.destroy(false); // keep DOM — Flow owns the children
|
|
this.grid = undefined;
|
|
}
|
|
|
|
/** Called from the server once on attach (and again if the element is
|
|
* re-attached). Idempotent: tears down any previous grid instance
|
|
* first so it's safe to call more than once. */
|
|
async initGrid(optionsJson: string, storageKey: string | null) {
|
|
this.grid?.destroy(false);
|
|
this.storageKey = storageKey;
|
|
|
|
this.classList.add('grid-stack');
|
|
const options = JSON.parse(optionsJson);
|
|
this.grid = GridStack.init(options, this);
|
|
|
|
this.registerChildren();
|
|
this.restore();
|
|
|
|
this.grid.on('change added removed', () => this.schedulePersist());
|
|
this.grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
|
|
}
|
|
|
|
/** Drops any saved layout for this grid and re-applies the positions
|
|
* currently declared server-side (the gs-* attributes on each child). */
|
|
clearStorage() {
|
|
if (this.storageKey) {
|
|
localStorage.removeItem(STORAGE_PREFIX + this.storageKey);
|
|
}
|
|
if (!this.grid) return;
|
|
for (const el of Array.from(this.children) as HTMLElement[]) {
|
|
if (!el.classList.contains('grid-stack-item')) continue;
|
|
this.grid.update(el, {
|
|
x: this.intAttr(el, 'gs-x'),
|
|
y: this.intAttr(el, 'gs-y'),
|
|
w: this.intAttr(el, 'gs-w'),
|
|
h: this.intAttr(el, 'gs-h'),
|
|
});
|
|
}
|
|
}
|
|
|
|
private onMutation(mutations: MutationRecord[]) {
|
|
if (!this.grid) return;
|
|
for (const mutation of mutations) {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (!(node instanceof HTMLElement)) return;
|
|
if (!node.classList.contains('grid-stack-item')) return;
|
|
if ((node as any).gridstackNode) return;
|
|
this.grid!.makeWidget(node);
|
|
});
|
|
mutation.removedNodes.forEach((node) => {
|
|
if (!(node instanceof HTMLElement)) return;
|
|
if (!(node as any).gridstackNode) return;
|
|
this.grid!.removeWidget(node, false);
|
|
});
|
|
}
|
|
}
|
|
|
|
private registerChildren() {
|
|
for (const el of Array.from(this.children) as HTMLElement[]) {
|
|
if (!el.classList.contains('grid-stack-item')) continue;
|
|
if ((el as any).gridstackNode) continue;
|
|
this.grid!.makeWidget(el);
|
|
}
|
|
}
|
|
|
|
private schedulePersist() {
|
|
clearTimeout(this.persistTimer);
|
|
this.persistTimer = setTimeout(() => this.persist(), 150);
|
|
}
|
|
|
|
private persist() {
|
|
if (!this.grid) return;
|
|
const nodes = this.grid.save(false) as GridStackNode[];
|
|
if (this.storageKey) {
|
|
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
|
|
}
|
|
(this as any).$server?.onLayoutChange(JSON.stringify(nodes));
|
|
}
|
|
|
|
private restore() {
|
|
if (!this.storageKey || !this.grid) return;
|
|
const raw = localStorage.getItem(STORAGE_PREFIX + this.storageKey);
|
|
if (!raw) return;
|
|
|
|
let nodes: GridStackNode[];
|
|
try {
|
|
nodes = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
for (const node of nodes) {
|
|
if (!node.id) continue;
|
|
const el = this.querySelector(`:scope > [gs-id="${node.id}"]`) as HTMLElement | null;
|
|
if (!el) continue; // item no longer present server-side — skip, degrade gracefully
|
|
this.grid.update(el, { x: node.x, y: node.y, w: node.w, h: node.h });
|
|
}
|
|
}
|
|
|
|
private intAttr(el: HTMLElement, name: string): number | undefined {
|
|
const value = el.getAttribute(name);
|
|
return value === null ? undefined : parseInt(value, 10);
|
|
}
|
|
}
|
|
|
|
customElements.define('grid-stack-layout', GridStackLayout);
|