added gridstack
This commit is contained in:
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user