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} (an item passed in directly is added as is). *

* Appends through the element API rather than calling {@code add(item)}: * a single-argument call resolves to this overload, not the varargs one, * so delegating would recurse into itself. */ public GridStackItem add(Component content) { GridStackItem item = content instanceof GridStackItem existing ? existing : new GridStackItem(content); getElement().appendChild(item.getElement()); 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; } } }