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
@@ -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) {
}
}
@@ -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 <a href="https://gridstack.js.org">gridstack.js</a> —
* 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.
* <p>
* 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<String, Object> 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<GridStackItem.Position> getLayout() {
List<GridStackItem.Position> 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<GridStackItem.Position> 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<GridStackItem> 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<LayoutChangeEvent> listener) {
return addListener(LayoutChangeEvent.class, listener);
}
public static class LayoutChangeEvent extends ComponentEvent<GridStackLayout> {
private final List<GridStackItem.Position> positions;
LayoutChangeEvent(GridStackLayout source, boolean fromClient, List<GridStackItem.Position> positions) {
super(source, fromClient);
this.positions = positions;
}
public List<GridStackItem.Position> getPositions() {
return positions;
}
}
}