added gridstack

This commit is contained in:
Pit Friedrich
2026-07-25 20:51:53 +02:00
parent 198988da35
commit c2d29f0635
3 changed files with 425 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
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);