# Grid Panel (layout editor) Links: [Home](../index.md) Place rectangular blocks on a cell grid that grows on its own, then snapshot and restore the whole layout as one JSON object. ## Overview `gadgets/grid-panel` is a layout editor for tiled dashboards, written by SGApps.IO. The user clicks two cells (or double-clicks one) and a block covering that rectangle appears; the grid keeps a margin of spare cells around the blocks so there is always room to grow. Every block is an `ApplicationPrototype` with a DOM container where you mount your own widget, and `panel.settings()` turns the layout -- geometry plus whatever metadata you attached to each block -- into a plain object that `panel.settings(snapshot)` rebuilds. The module is made of three objects, each built by its own file and each an `ApplicationPrototype` instance (`on`, `once`, `off`, `emit`, `bind` are available on all of them): | Object | File | Role | |--------|------|------| | **panel** | `gadgets/grid-panel.js` | `GridPanelBuilder(conf)` -- owns the block list, sizes the grid, wires the click interactions, serialises the layout | | **block** | `gadgets/grid-panel/block.js` | one rectangle: cell coordinates, span, a DOM node with a content container, the configuration object it was created from | | **grid** | `gadgets/grid-panel/grid.js` | the W x H matrix of **cells**: creates and removes cell nodes, tracks the active / hovered cell, generates the per-instance ` grid.styleNode()
one per cell, cell.node()
...
one per block, block.node()
block.container()
... ``` Cells and blocks are absolutely positioned, so `.grid_panel` has **no intrinsic size**: mount it in an element with an explicit height (and `overflow: auto` if the grid may outgrow it). ## Advantages - **Two-click authoring** -- click a cell, click another: the block spans the rectangle, with a live blue preview while the mouse moves - **Self-sizing grid** -- the grid always keeps `drawPadding` spare cells past the farthest block, the active cell and the hovered cell, clamped by `minWidth` / `maxWidth` / `minHeight` / `maxHeight` - **One JSON for the whole layout** -- `panel.settings()` returns every block's configuration; `panel.settings(snapshot)` rebuilds it and fires `evt.block.add` per block so content can be re-mounted - **Metadata travels with the block** -- anything you pass to `blockAdd()` besides `geometric` stays on `block.settings()` and is part of the snapshot - **Plain DOM containers** -- `block.container()` is an ordinary `
`: mount a chart, a table, a component, anything - **Event driven** -- `evt.block.add`, `evt.block.remove`, `evt.settings.update` on the panel, `geometric-update` on a block, `statusUpdate` / `evt.grid.render` on the grid, plus the framework's `before` / `on` / `after` hooks on every bound method - **Programmable geometry** -- `block.x()`, `y()`, `width()`, `height()` setters re-render the panel; the grid follows - **Overridable look** -- one small stylesheet with stable class names; replace the editor visuals in production ## Getting Started ```js App.require('gadgets/grid-panel').then(function (GridPanelBuilder) { var panel = GridPanelBuilder(); document.getElementById('mount').appendChild(panel.node()); // #mount needs a height panel.on('evt.block.add', function (block) { // block.container() is the
inside the block block.container().textContent = block.width() + 'x' + block.height(); }); // 4 cells wide, 3 cells high, top-left corner -- click two cells for the next one panel.blockAdd({ geometric: { x: 0, y: 0, width: 4, height: 3 } }); }); ``` `gadgets/grid-panel` must be registered with `Application.moduleRegister` first -- `lib.js` at the repository root does that for every module: ```js App.require('/application-prototype-modules/lib.js').then(function (loader) { loader(); // Application.moduleRegister('/application-prototype-modules', ['gadgets/grid-panel', ...]) }); ``` The grid module requires the framework's `extensions/prototype` (for the `_()` DOM chain) and `uri-load` (to inject its stylesheet), so both must be resolvable by name -- `lib()` of the framework registers them. ## API Reference ### `GridPanelBuilder(conf)` -- the panel Returns the panel, an `ApplicationPrototype`. The `conf` argument is **accepted but ignored**: the configuration is hard-coded and only reachable through `panel.config()`. ```js var panel = GridPanelBuilder(); panel.config().drawPadding = 1; // the only way to change a setting -- takes effect at the next render() ``` #### Default configuration (`panel.config()`) | Key | Default | Meaning | |-----|---------|---------| | `minWidth` | `4` | the grid is never narrower than this many cells | | `minHeight` | `3` | the grid is never shorter than this many cells | | `maxWidth` | `24` | the grid is never wider than this many cells (a block past it keeps its coordinates and is drawn beyond the last cell column, over no cells) | | `maxHeight` | `10000` | the grid is never taller than this many cells | | `drawPadding` | `2` | spare cells kept past the farthest block / active cell / hovered cell | | `blocks` | `[]` | the live block list (`panel.blocks()` returns a copy of it) | | `blockId` | `0` | monotonic counter behind `block.id` | | `node` | `
` | the root element, `panel.node()` | `config()` returns the live object -- edits are visible immediately, but nothing re-renders on its own; call `panel.render()` after changing the limits. #### Panel methods | Method | Returns | Description | |--------|---------|-------------| | `node()` | HTMLElement | the `
` to mount | | `config()` | Object | the live configuration above (no lifecycle hooks) | | `grid()` | grid | the grid instance (see below) | | `blocks()` | block[] | a **copy** of the block list, in creation order (no lifecycle hooks) | | `blockAdd(blockConfiguration)` | `undefined` (`false` when a `beforeBlockAdd` handler vetoed) | creates a block from `blockConfiguration` (see the block factory: `geometric` defaults to `{x: 0, y: 0, width: 1, height: 1}`, `node` is set on the object), attaches the panel to the block (`block.GridPanel(panel)`), assigns `block.id`, subscribes to its `geometric-update`, appends `block.node()` to `panel.node()`, pushes it to the list, emits `evt.settings.update` then `evt.block.add(block)`. It does **not** return the block -- read it from the `evt.block.add` argument or `panel.blocks()[panel.blocks().length - 1]` | | `blockRemove(block)` | `undefined` | clears the block's panel reference, filters it out of the list by `id`, detaches `block.node()` from the DOM, emits `evt.settings.update` then `evt.block.remove(block)` | | `render()` | `undefined` | re-positions every block (`grid.updateCoords(block)`) and sets the grid width / height with the algorithm below | | `settings()` | `{ grid, blocks }` | getter -- see [What `settings()` returns](#what-settings-returns) | | `settings(snapshot)` | `undefined` | setter -- removes every current block with `blockRemove()`, calls `grid.settings(snapshot.grid)` when it is an object (a no-op), then `blockAdd(blockConfiguration)` for every entry of `snapshot.blocks` when it is an array | `blocks()`, `config()` are plain methods; every other method is bound with the default lifecycle configuration, so `panel.on('beforeBlockAdd', fn)` (return `false` to veto), `'onBlockAdd'`, `'afterBlockAdd'` (next tick) and the same for `blockRemove`, `render`, `settings`, `node`, `grid` all work. The hook handlers receive the **method arguments**: `afterBlockAdd` gets the configuration object, not the block. `render()` is called automatically: on the next tick after every `blockAdd()` / `blockRemove()` (via `afterBlockAdd` / `afterBlockRemove`), on every block `geometric-update`, on every grid `update` (width / height / cellSize change) and on every grid `statusUpdate` (click or hover). It is **not** called synchronously by `blockAdd()`: right after the call the block node is in the DOM but still has no `left` / `top` / `width` / `height` inline styles. #### What `settings()` returns `panel.settings()` (no argument) returns a new object: ```js { grid: {}, // always {} -- grid.settings() is a stub blocks: [ block.settings(), block.settings(), ... ] // creation order } ``` Each entry is the **live configuration object** of that block: the very object passed to `blockAdd()`, to which the block factory added a `node` key (the block's `
` element) and a `geometric` key when it was missing. Mutating an entry mutates the block (`entry.geometric.x = 3` moves it at the next render, without any event). `JSON.stringify(panel.settings())` therefore contains, per block, `geometric`, every serialisable key you passed to `blockAdd()` and a junk `"node": {}` (a DOM element serialises as an empty object); functions such as a `renderMethods` key are dropped: ```json {"grid":{},"blocks":[ {"geometric":{"x":0,"y":0,"width":4,"height":3},"renderParams":{"type":"chart"},"node":{}}, {"geometric":{"x":4,"y":0,"width":2,"height":3},"node":{}} ]} ``` Not in the snapshot: the grid's `cellSize`, `width` and `height` (width and height are re-derived by `render()` after a restore, `cellSize` simply stays whatever the grid currently has -- `40` on a fresh panel), the block `id`s (re-assigned on restore) and the content of `block.container()`. See [Persistence Contract](#persistence-contract) for the recommended wrapper. #### The `render()` sizing algorithm ```text pad = drawPadding + 1 // 3 by default activeCell = grid.activeCell() // { cell, time } or false hoveredCell = grid.hoveredCell() wActive = activeCell ? activeCell.cell.x() + pad : 0 // hovered counts only while a cell is active hActive = activeCell ? activeCell.cell.y() + pad : 0 wHover = activeCell && hoveredCell ? hoveredCell.cell.x() + pad : 0 hHover = activeCell && hoveredCell ? hoveredCell.cell.y() + pad : 0 for each block: grid.updateCoords(block) // left/top/width/height = cellSize * geometry wBox = max(wBox, block.x() + block.width()) hBox = max(hBox, block.y() + block.height()) if (wBox) wBox += drawPadding // 2 spare columns past the farthest block if (hBox) hBox += drawPadding grid.width ( min(max(minWidth, max(wActive, wHover, wBox)), maxWidth ) ) grid.height( min(max(minHeight, max(hActive, hHover, hBox)), maxHeight) ) ``` In practice: the grid keeps `drawPadding` (2) free cells to the right of and below the farthest block edge, the active cell and, while a cell is active, the hovered cell. With the defaults an empty panel is 4 x 3; one block at `{x: 0, y: 0, width: 3, height: 2}` plus one at `{x: 3, y: 1, width: 2, height: 2}` give a 7 x 5 grid. `grid.width(n)` / `grid.height(n)` emit `update` only when the value changes; `update` runs `grid.render()` (cells are added / removed) and the panel listens to it to run `panel.render()` again (re-entrantly, from inside the first `render()`) -- the cascade stops as soon as the values are stable, so one geometry change produces one `evt.grid.render` per dimension that actually changed (two when both width and height change, none when the grid already fits), and a `cellSize` change one more. #### Built-in interactions | Gesture | Effect | |---------|--------| | click a cell | the cell becomes **active** (`.grid_cell_active`, red); `statusUpdate('activeCell', cell, oldCell)` where `oldCell` is the previously active cell or `undefined` | | click the active cell again | deactivates it (toggle), nothing is created; `statusUpdate('activeCell', cell, cell)` with `grid.activeCell()` already `false` | | move the mouse over cells while a cell is active | the cells inside the rectangle between the active and the hovered cell get `.grid_cell_selected` (blue preview); the grid grows to keep `drawPadding` cells past the hovered cell | | click a second cell | `blockAdd({ geometric: { x: min, y: min, width: abs(dx) + 1, height: abs(dy) + 1 } })`, then the active cell is cleared (`grid.activeCell(false)` + `cell.active(false)` on the second cell). The `.grid_cell_selected` class stays on the second cell until the mouse moves to another cell (the selection is only repainted on the next `statusUpdate`) | | double-click a cell | `blockAdd({ geometric: { x, y, width: 1, height: 1 } })` (the `evt.grid.cell.dblclick` handler); the active cell is cleared | | double-click a cell while **another** cell is active | **two** blocks: the first click of the double-click completes the two-click rectangle, then the `dblclick` adds the 1 x 1 block | There is no built-in drag-to-move, drag-to-resize, delete gesture or overlap check: blocks may overlap each other, and a block covers the cells underneath it (no `pointer-events: none`), so a rectangle cannot be started from a covered cell. Implement moving / resizing with the block setters (`x()`, `y()`, `width()`, `height()`) and deleting with `blockRemove()`. --- ### `block` -- one rectangle Built by `gadgets/grid-panel/block.js` for every `blockAdd(blockConfiguration)` call; you get it from `evt.block.add` / `evt.block.remove` and `panel.blocks()`. The factory sanitises nothing beyond this: ```js if (!(typeof config === 'object' && config)) config = {}; config.node = document.createElement('div'); // overwrites any "node" key config.geometric = config.geometric || { x: 0, y: 0, width: 1, height: 1 }; // only when missing -- a partial geometric is NOT completed ``` `block.node()` is `
`. | Method | Returns | Description | |--------|---------|-------------| | `node()` | HTMLElement | the `
`, positioned by the panel with inline `left` / `top` / `width` / `height` | | `container()` | HTMLElement | the `
` -- mount your content here (`querySelector` on every call) | | `clear()` | `undefined` | `container().innerHTML = ''` | | `x(n)` | number | cell column; setter accepts a **number `>= 0`** (floats included), anything else is ignored; an accepted value emits `geometric-update`. Always returns the current value | | `y(n)` | number | cell row; same rules as `x()` | | `width(n)` | number | span in cells; setter accepts a **number `> 1`** only -- `width(1)` is silently ignored, the getter keeps returning the old span. Emits `geometric-update` when accepted | | `height(n)` | number | same rules as `width()` | | `settings()` | Object | the **live** configuration object: `geometric`, your own keys, `node` | | `GridPanel(panel)` | panel or `null` | the owning panel. An object argument sets it, a falsy non-`undefined` argument (`null`, `false`, `0`, `''`) clears it, no argument (or any truthy non-object such as a string) just reads it. The panel calls `GridPanel(app)` in `blockAdd()` and `GridPanel(null)` in `blockRemove()` | | `detachFromGrid()` | boolean | `panel.blockRemove(block)` on the owning panel; `true` when there was one, `false` otherwise | `block.id` is a **plain property** (not a method) set by `blockAdd()` from the panel's `blockId` counter: `1, 2, 3, ...` per panel, never reused, re-assigned after a restore. All block methods are bound with the default lifecycle configuration: `block.on('afterWidth', fn)`, `block.on('beforeClear', fn)`, ... work, and every getter call (`x()`, `width()`, ...) emits its `before` / `on` hooks synchronously and its `after` hook on the next tick -- cheap while nobody listens, but not free. --- ### `grid` -- the cell matrix Built by `gadgets/grid-panel/grid.js`; `panel.grid()` returns it. On module load (once, not per instance) it injects `gadgets/grid-panel/grid/style.css` into `` through `uri-load`. | Method | Returns | Description | |--------|---------|-------------| | `node()` | HTMLElement | the `
` (child of the panel node) | | `styleNode()` | HTMLElement | the `
` holding the generated `