mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
f728b0002c
commit
7206847c97
3 changed files with 182 additions and 6 deletions
|
|
@ -1165,6 +1165,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
|
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) => {
|
app.post("/admin/layouts/:id/cells/:cellId/delete", async (event) => {
|
||||||
const layoutId = Number(getRouterParam(event, "id"));
|
const layoutId = Number(getRouterParam(event, "id"));
|
||||||
const cellId = Number(getRouterParam(event, "cellId"));
|
const cellId = Number(getRouterParam(event, "cellId"));
|
||||||
|
|
|
||||||
147
server/src/web-static/layout-editor.js
Normal file
147
server/src/web-static/layout-editor.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -2364,6 +2364,7 @@ export function renderCell(
|
||||||
<div
|
<div
|
||||||
class="layout-cell"
|
class="layout-cell"
|
||||||
id={`cell-${String(c.id)}`}
|
id={`cell-${String(c.id)}`}
|
||||||
|
data-cell-id={String(c.id)}
|
||||||
style={style}
|
style={style}
|
||||||
hx-get={cellEditUrl}
|
hx-get={cellEditUrl}
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
|
|
@ -2455,12 +2456,26 @@ export function renderGrid(
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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
|
<div
|
||||||
class="layout-builder"
|
class="layout-builder"
|
||||||
|
data-layout-editor={String(layoutId)}
|
||||||
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
|
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
|
||||||
>
|
>
|
||||||
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
|
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/static/layout-editor.js" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue