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; 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);