feat(layout-editor): visual drag-resize grid editor for layout cells

Browser-side layout editor (no build step, vanilla JS):
  - Click to select cells
  - Drag edges (right/bottom/corner handles) to resize col_span/row_span
  - Drag cells to reposition (row/col) with grid-aware snap
  - Visual feedback: selection outline, resize handle highlights, drag opacity

Server: POST /admin/layouts/:id/cells/:cellId/move route for drag-drop
repositioning. Existing /resize route handles span changes.

CSS: inline resize handle styles + selection state. Handles appear on
hover (6px edge bars + 12px corner square).

layout-editor.js loaded via /static/. Activates on any grid with
data-layout-editor="<layoutId>" attribute. Compatible with htmx —
re-initializes after swap via htmx:afterSettle listener.

data-cell-id attribute added to each .layout-cell div for JS targeting.
This commit is contained in:
Mitchell R 2026-05-23 02:28:42 +02:00
parent f728b0002c
commit 7206847c97
No known key found for this signature in database
3 changed files with 182 additions and 6 deletions

View file

@ -1165,6 +1165,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
// Visual editor: drag-to-move a cell to a new grid position.
app.post("/admin/layouts/:id/cells/:cellId/move", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const body = await readBody<{ row: number; col: number }>(event);
const row = Number(body?.row ?? 0);
const col = Number(body?.col ?? 0);
if (Number.isInteger(row) && Number.isInteger(col) && row >= 0 && col >= 0) {
await deps.repo.updateLayoutCell(cellId, { row, col } as any);
notifyKiosks();
}
return { ok: true };
});
app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));

View file

@ -0,0 +1,147 @@
/**
* BetterFrame visual layout editor.
*
* Enhances the server-rendered CSS grid (.layout-builder) with:
* - Click-to-select cells
* - Drag edges to resize (col_span / row_span)
* - Drag cells to reposition (row / col)
* - Visual feedback (selection highlight, resize handles)
*
* No external deps. Communicates with the server via htmx-compatible
* fetch calls (POST to /admin/layouts/:id/cells/:cellId with the new
* row/col/spans). Server re-renders the grid fragment.
*
* Activated by adding data-layout-editor="<layoutId>" to the grid container.
*/
(function () {
"use strict";
document.addEventListener("DOMContentLoaded", init);
// Also init on htmx swap (grid may be re-rendered).
document.addEventListener("htmx:afterSettle", init);
function init() {
document.querySelectorAll("[data-layout-editor]").forEach(setupEditor);
}
function setupEditor(grid) {
if (grid._bfEditorInit) return;
grid._bfEditorInit = true;
const layoutId = grid.getAttribute("data-layout-editor");
const cells = grid.querySelectorAll(".layout-cell");
cells.forEach(function (cell) {
// Add resize handles.
["right", "bottom", "corner"].forEach(function (dir) {
var handle = document.createElement("div");
handle.className = "bf-resize-handle bf-resize-" + dir;
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
e.stopPropagation();
startResize(grid, cell, dir, e, layoutId);
});
cell.appendChild(handle);
});
// Click to select (for future inline editing).
cell.addEventListener("click", function () {
cells.forEach(function (c) { c.classList.remove("bf-selected"); });
cell.classList.add("bf-selected");
});
// Drag to move.
cell.setAttribute("draggable", "true");
cell.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", cell.getAttribute("data-cell-id"));
cell.classList.add("bf-dragging");
});
cell.addEventListener("dragend", function () {
cell.classList.remove("bf-dragging");
});
});
// Drop target for repositioning.
grid.addEventListener("dragover", function (e) { e.preventDefault(); });
grid.addEventListener("drop", function (e) {
e.preventDefault();
var cellId = e.dataTransfer.getData("text/plain");
if (!cellId) return;
// Calculate target grid position from mouse position.
var rect = grid.getBoundingClientRect();
var style = getComputedStyle(grid);
var cols = style.gridTemplateColumns.split(" ").length;
var rows = style.gridTemplateRows.split(" ").length;
var cellW = rect.width / cols;
var cellH = rect.height / rows;
var targetCol = Math.floor((e.clientX - rect.left) / cellW);
var targetRow = Math.floor((e.clientY - rect.top) / cellH);
// POST the new position to the server.
fetch("/admin/layouts/" + layoutId + "/cells/" + cellId + "/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ row: targetRow, col: targetCol }),
}).then(function () {
// Trigger htmx to re-render the grid.
htmx.trigger(grid, "bf-grid-changed");
});
});
}
var resizeState = null;
function startResize(grid, cell, direction, startEvent, layoutId) {
var cellId = cell.getAttribute("data-cell-id");
var rect = grid.getBoundingClientRect();
var style = getComputedStyle(grid);
var cols = style.gridTemplateColumns.split(" ").length;
var rows = style.gridTemplateRows.split(" ").length;
var cellW = rect.width / cols;
var cellH = rect.height / rows;
var startX = startEvent.clientX;
var startY = startEvent.clientY;
var startColSpan = parseInt(cell.style.gridColumnEnd?.replace("span ", "") || "1");
var startRowSpan = parseInt(cell.style.gridRowEnd?.replace("span ", "") || "1");
function onMove(e) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
var newColSpan = startColSpan;
var newRowSpan = startRowSpan;
if (direction === "right" || direction === "corner") {
newColSpan = Math.max(1, startColSpan + Math.round(dx / cellW));
}
if (direction === "bottom" || direction === "corner") {
newRowSpan = Math.max(1, startRowSpan + Math.round(dy / cellH));
}
cell.style.gridColumnEnd = "span " + newColSpan;
cell.style.gridRowEnd = "span " + newRowSpan;
}
function onUp(e) {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
var newColSpan = parseInt(cell.style.gridColumnEnd?.replace("span ", "") || "1");
var newRowSpan = parseInt(cell.style.gridRowEnd?.replace("span ", "") || "1");
if (newColSpan !== startColSpan || newRowSpan !== startRowSpan) {
fetch("/admin/layouts/" + layoutId + "/cells/" + cellId + "/resize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ col_span: newColSpan, row_span: newRowSpan }),
}).then(function () {
htmx.trigger(grid, "bf-grid-changed");
});
}
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
})();

View file

@ -2364,6 +2364,7 @@ export function renderCell(
<div
class="layout-cell"
id={`cell-${String(c.id)}`}
data-cell-id={String(c.id)}
style={style}
hx-get={cellEditUrl}
hx-target="this"
@ -2455,12 +2456,26 @@ export function renderGrid(
}
return (
<div
class="layout-builder"
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
>
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
</div>
<>
<style>{`
.bf-resize-handle { position:absolute; z-index:10; }
.bf-resize-right { right:0; top:0; width:6px; height:100%; cursor:col-resize; }
.bf-resize-bottom { left:0; bottom:0; width:100%; height:6px; cursor:row-resize; }
.bf-resize-corner { right:0; bottom:0; width:12px; height:12px; cursor:nwse-resize; }
.bf-resize-handle:hover { background:rgba(30,64,175,0.3); }
.layout-cell.bf-selected { outline:2px solid #1e40af; outline-offset:-2px; }
.layout-cell.bf-dragging { opacity:0.5; }
.layout-cell { position:relative; }
`}</style>
<div
class="layout-builder"
data-layout-editor={String(layoutId)}
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
>
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
</div>
<script src="/static/layout-editor.js" />
</>
);
}