diff --git a/src/main/frontend/components/grid-stack.ts b/src/main/frontend/components/grid-stack.ts new file mode 100644 index 0000000..8d2f893 --- /dev/null +++ b/src/main/frontend/components/grid-stack.ts @@ -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; + + 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); diff --git a/src/main/java/com/example/components/GridStackItem.java b/src/main/java/com/example/components/GridStackItem.java new file mode 100644 index 0000000..a9f180c --- /dev/null +++ b/src/main/java/com/example/components/GridStackItem.java @@ -0,0 +1,86 @@ +package com.example.components; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.html.Div; + +import java.util.UUID; + +/** + * A single cell inside a {@link GridStackLayout}. Position/size are written as + * {@code gs-*} attributes, which is how gridstack.js reads a widget's initial + * placement when {@code makeWidget} converts it client-side (see grid-stack.ts). + */ +public class GridStackItem extends Div { + + private final Div content = new Div(); + + public GridStackItem(Component... content) { + this(UUID.randomUUID().toString(), 0, 0, 4, 3, content); + } + + public GridStackItem(String id, int x, int y, int w, int h, Component... content) { + addClassName("grid-stack-item"); + getElement().setAttribute("gs-id", id); + getElement().setAttribute("gs-x", String.valueOf(x)); + getElement().setAttribute("gs-y", String.valueOf(y)); + getElement().setAttribute("gs-w", String.valueOf(w)); + getElement().setAttribute("gs-h", String.valueOf(h)); + + this.content.addClassName("grid-stack-item-content"); + add(this.content); + this.content.add(content); + } + + public void add(Component... components) { + content.add(components); + } + + public void removeAll() { + content.removeAll(); + } + + public String getItemId() { + return getElement().getAttribute("gs-id"); + } + + public GridStackItem setResizable(boolean resizable) { + setBooleanAttribute("gs-no-resize", !resizable); + return this; + } + + public GridStackItem setMovable(boolean movable) { + setBooleanAttribute("gs-no-move", !movable); + return this; + } + + /** Writes an explicit "true" (gridstack reads this via its own + * {@code Utils.toBool}) or removes the attribute — avoids relying on + * Vaadin's HTML5 empty-string boolean-attribute convention, which + * gridstack's own boolean parsing does not document matching. */ + private void setBooleanAttribute(String name, boolean value) { + if (value) { + getElement().setAttribute(name, "true"); + } else { + getElement().removeAttribute(name); + } + } + + public GridStackItem setMinSize(int minWidth, int minHeight) { + getElement().setAttribute("gs-min-w", String.valueOf(minWidth)); + getElement().setAttribute("gs-min-h", String.valueOf(minHeight)); + return this; + } + + public GridStackItem setPosition(int x, int y, int w, int h) { + getElement().setAttribute("gs-x", String.valueOf(x)); + getElement().setAttribute("gs-y", String.valueOf(y)); + getElement().setAttribute("gs-w", String.valueOf(w)); + getElement().setAttribute("gs-h", String.valueOf(h)); + return this; + } + + /** Snapshot of a widget's grid placement, as reported by the client after a + * drag/resize (see {@link GridStackLayout#onLayoutChange(String)}). */ + public record Position(String id, int x, int y, int w, int h) { + } +} diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java new file mode 100644 index 0000000..e3c9984 --- /dev/null +++ b/src/main/java/com/example/components/GridStackLayout.java @@ -0,0 +1,210 @@ +package com.example.components; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.HasSize; +import com.vaadin.flow.component.HasStyle; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.shared.Registration; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Vaadin wrapper around gridstack.js — + * a draggable, resizable grid container. Children must be {@link GridStackItem} + * instances (or a plain {@link Component}, which gets auto-wrapped); gridstack + * manipulates the real DOM nodes directly, so this component keeps its children + * in light DOM (see {@code grid-stack.ts}) rather than routing them through a + * Lit render root the way {@link ApexChart} does. + *

+ * Layout changes made by drag/resize are, by default, only persisted in the + * browser's {@code localStorage} (see {@link #setStorageKey(String)}); the + * server is notified via {@link #addLayoutChangeListener}, and item positions + * are kept in sync in {@link #onLayoutChange(String)} so {@link #getLayout()} + * stays truthful across a reload of this component (though not across a page + * reload, since server-side item state is not itself persisted). + */ +@Tag("grid-stack-layout") +@NpmPackage(value = "gridstack", version = "13.1.0") +@JsModule("./components/grid-stack.ts") +@CssImport("gridstack/dist/gridstack.min.css") +public class GridStackLayout extends Component implements HasSize, HasStyle { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + private final Map options = new LinkedHashMap<>(); + private String storageKey; + + public GridStackLayout() { + options.put("column", 12); + options.put("cellHeight", "120px"); + options.put("margin", "8px"); + options.put("minRow", 1); + options.put("float", true); + options.put("animate", true); + } + + public GridStackLayout setColumn(int column) { + options.put("column", column); + return this; + } + + public GridStackLayout setCellHeight(String cellHeight) { + options.put("cellHeight", cellHeight); + return this; + } + + public GridStackLayout setMargin(String margin) { + options.put("margin", margin); + return this; + } + + public GridStackLayout setMinRow(int minRow) { + options.put("minRow", minRow); + return this; + } + + public GridStackLayout setFloat(boolean floatItems) { + options.put("float", floatItems); + return this; + } + + public GridStackLayout setAnimate(boolean animate) { + options.put("animate", animate); + return this; + } + + public GridStackLayout setStaticGrid(boolean staticGrid) { + options.put("staticGrid", staticGrid); + return this; + } + + /** + * Enables browser-localStorage persistence of the layout under the given + * key (shared across sessions/tabs on the same origin — pick something + * unique per grid instance/view). Pass {@code null} to disable. + */ + public GridStackLayout setStorageKey(String storageKey) { + this.storageKey = storageKey; + return this; + } + + public void add(GridStackItem... items) { + for (GridStackItem item : items) { + getElement().appendChild(item.getElement()); + } + } + + /** Convenience overload: wraps a plain component in a default-sized {@link GridStackItem}. */ + public GridStackItem add(Component content) { + GridStackItem item = new GridStackItem(content); + add(item); + return item; + } + + public void remove(GridStackItem... items) { + for (GridStackItem item : items) { + getElement().removeChild(item.getElement()); + } + } + + public void removeAll() { + getElement().removeAllChildren(); + } + + /** Restores every item to the position/size currently declared server-side + * (its {@code gs-*} attributes) and clears any saved localStorage layout. */ + public void resetLayout() { + getElement().callJsFunction("clearStorage"); + } + + public List getLayout() { + List positions = new ArrayList<>(); + getElement().getChildren().forEach(child -> { + if (!(child.getComponent().orElse(null) instanceof GridStackItem item)) { + return; + } + positions.add(new GridStackItem.Position( + item.getItemId(), + intAttr(item, "gs-x"), + intAttr(item, "gs-y"), + intAttr(item, "gs-w"), + intAttr(item, "gs-h"))); + }); + return positions; + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + getElement().callJsFunction("initGrid", MAPPER.writeValueAsString(options), storageKey); + } + + @ClientCallable + private void onLayoutChange(String nodesJson) { + JsonNode nodes = MAPPER.readTree(nodesJson); + List positions = new ArrayList<>(); + + for (JsonNode node : nodes) { + String id = node.path("id").asString(null); + if (id == null) continue; + + findItem(id).ifPresent(item -> item.setPosition( + node.path("x").asInt(), + node.path("y").asInt(), + node.path("w").asInt(), + node.path("h").asInt())); + + positions.add(new GridStackItem.Position( + id, + node.path("x").asInt(), + node.path("y").asInt(), + node.path("w").asInt(), + node.path("h").asInt())); + } + + fireEvent(new LayoutChangeEvent(this, true, positions)); + } + + private java.util.Optional findItem(String id) { + return getElement().getChildren() + .map(child -> child.getComponent().orElse(null)) + .filter(GridStackItem.class::isInstance) + .map(GridStackItem.class::cast) + .filter(item -> id.equals(item.getItemId())) + .findFirst(); + } + + private int intAttr(Component component, String name) { + String value = component.getElement().getAttribute(name); + return value == null ? 0 : Integer.parseInt(value); + } + + public Registration addLayoutChangeListener(ComponentEventListener listener) { + return addListener(LayoutChangeEvent.class, listener); + } + + public static class LayoutChangeEvent extends ComponentEvent { + private final List positions; + + LayoutChangeEvent(GridStackLayout source, boolean fromClient, List positions) { + super(source, fromClient); + this.positions = positions; + } + + public List getPositions() { + return positions; + } + } +}