mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
fix(layouts): push cells on expand
This commit is contained in:
parent
02e57a5d54
commit
ab8eeb1d09
2 changed files with 133 additions and 74 deletions
|
|
@ -89,6 +89,83 @@ function rtspWithCredentials(raw: string, username: string, password: string): s
|
|||
}
|
||||
}
|
||||
|
||||
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
|
||||
function shiftCellsForExpansion(
|
||||
deps: AdminDeps,
|
||||
layoutId: number,
|
||||
cellId: number,
|
||||
direction: "left" | "right" | "above" | "bottom",
|
||||
): void {
|
||||
const cell = deps.repo.getLayoutCellById(cellId);
|
||||
if (!cell || cell.layout_id !== layoutId) return;
|
||||
|
||||
const cells = deps.repo.layoutCells(layoutId).filter((c) => c.id !== cellId);
|
||||
const rowStart = cell.row;
|
||||
const rowEnd = cell.row + cell.row_span;
|
||||
const colStart = cell.col;
|
||||
const colEnd = cell.col + cell.col_span;
|
||||
|
||||
if (direction === "right") {
|
||||
for (const c of cells) {
|
||||
if (c.col >= colEnd && rangesOverlap(c.row, c.row + c.row_span, rowStart, rowEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { col: c.col + 1 });
|
||||
}
|
||||
}
|
||||
deps.repo.updateLayoutCell(cell.id, { col_span: cell.col_span + 1 });
|
||||
} else if (direction === "bottom") {
|
||||
for (const c of cells) {
|
||||
if (c.row >= rowEnd && rangesOverlap(c.col, c.col + c.col_span, colStart, colEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { row: c.row + 1 });
|
||||
}
|
||||
}
|
||||
deps.repo.updateLayoutCell(cell.id, { row_span: cell.row_span + 1 });
|
||||
} else if (direction === "left") {
|
||||
const insertCol = Math.max(0, cell.col - 1);
|
||||
for (const c of cells) {
|
||||
if (c.col >= insertCol && rangesOverlap(c.row, c.row + c.row_span, rowStart, rowEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { col: c.col + 1 });
|
||||
}
|
||||
}
|
||||
deps.repo.updateLayoutCell(cell.id, {
|
||||
col: insertCol,
|
||||
col_span: cell.col_span + 1,
|
||||
});
|
||||
} else if (direction === "above") {
|
||||
const insertRow = Math.max(0, cell.row - 1);
|
||||
for (const c of cells) {
|
||||
if (c.row >= insertRow && rangesOverlap(c.col, c.col + c.col_span, colStart, colEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { row: c.row + 1 });
|
||||
}
|
||||
}
|
||||
deps.repo.updateLayoutCell(cell.id, {
|
||||
row: insertRow,
|
||||
row_span: cell.row_span + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function shiftCellsForInsertion(
|
||||
deps: AdminDeps,
|
||||
layoutId: number,
|
||||
axis: "row" | "col",
|
||||
fromIndex: number,
|
||||
crossStart: number,
|
||||
crossEnd: number,
|
||||
): void {
|
||||
for (const c of deps.repo.layoutCells(layoutId)) {
|
||||
if (axis === "col") {
|
||||
if (c.col >= fromIndex && rangesOverlap(c.row, c.row + c.row_span, crossStart, crossEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { col: c.col + 1 });
|
||||
}
|
||||
} else if (c.row >= fromIndex && rangesOverlap(c.col, c.col + c.col_span, crossStart, crossEnd)) {
|
||||
deps.repo.updateLayoutCell(c.id, { row: c.row + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||
// ---- Overview -------------------------------------------------------------
|
||||
|
||||
|
|
@ -547,25 +624,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
if (direction === "right") {
|
||||
row = ref.row;
|
||||
col = ref.col + ref.col_span;
|
||||
shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1);
|
||||
} else if (direction === "bottom") {
|
||||
row = ref.row + ref.row_span;
|
||||
col = ref.col;
|
||||
shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1);
|
||||
} else if (direction === "left") {
|
||||
row = ref.row;
|
||||
if (ref.col === 0) {
|
||||
deps.repo.shiftCellsForLayout(layoutId, "col", 0, 1);
|
||||
col = 0;
|
||||
} else {
|
||||
col = ref.col - 1;
|
||||
}
|
||||
col = Math.max(0, ref.col - 1);
|
||||
shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1);
|
||||
} else if (direction === "above") {
|
||||
col = ref.col;
|
||||
if (ref.row === 0) {
|
||||
deps.repo.shiftCellsForLayout(layoutId, "row", 0, 1);
|
||||
row = 0;
|
||||
} else {
|
||||
row = ref.row - 1;
|
||||
}
|
||||
row = Math.max(0, ref.row - 1);
|
||||
shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1);
|
||||
}
|
||||
} else {
|
||||
// Explicit position — accept top-level row/col or nested position.
|
||||
|
|
@ -672,9 +743,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
const body = await readBody<Record<string, string | number>>(event);
|
||||
const dim = String(body?.["dim"] ?? "");
|
||||
const delta = Number(body?.["delta"] ?? 0) || 0;
|
||||
const direction = String(body?.["direction"] ?? "");
|
||||
|
||||
const cell = deps.repo.getLayoutCellById(cellId);
|
||||
if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) {
|
||||
if (
|
||||
cell
|
||||
&& cell.layout_id === layoutId
|
||||
&& (direction === "left" || direction === "right" || direction === "above" || direction === "bottom")
|
||||
) {
|
||||
shiftCellsForExpansion(deps, layoutId, cellId, direction);
|
||||
notifyKiosks();
|
||||
} else if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) {
|
||||
const current = dim === "row_span" ? cell.row_span : cell.col_span;
|
||||
const next = Math.max(1, current + delta);
|
||||
if (next !== current) {
|
||||
|
|
|
|||
|
|
@ -1418,23 +1418,28 @@ interface LayoutEditPageProps {
|
|||
|
||||
export const LAYOUT_BUILDER_CSS = `
|
||||
.layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; }
|
||||
.layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: hidden; }
|
||||
.layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: visible; }
|
||||
.layout-cell:hover { background: #f0f7ff; }
|
||||
.layout-cell.editing { background: #f9fafb; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; cursor: default; align-items: stretch; justify-content: stretch; text-align: left; font-weight: 400; color: inherit; padding: 8px; overflow: auto; }
|
||||
.layout-cell-empty-text { color: #999; font-size: 0.75rem; font-weight: 400; }
|
||||
.layout-cell-add { position: absolute; background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; opacity: 0; transition: opacity 0.2s; padding: 0; z-index: 2; }
|
||||
.layout-cell:hover .layout-cell-add { opacity: 1; }
|
||||
.layout-cell-add:hover { background: #1e40af; opacity: 1; }
|
||||
.layout-cell-add-top { top: -12px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-add-right { right: -12px; top: 50%; transform: translateY(-50%); }
|
||||
.layout-cell-add-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-add-left { left: -12px; top: 50%; transform: translateY(-50%); }
|
||||
.layout-cell-side { position: absolute; opacity: 0; transition: opacity 0.2s; z-index: 3; }
|
||||
.layout-cell:hover .layout-cell-side, .layout-cell-side:hover { opacity: 1; }
|
||||
.layout-cell-side-top { top: -12px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-side-right { right: -12px; top: 50%; transform: translateY(-50%); }
|
||||
.layout-cell-side-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-side-left { left: -12px; top: 50%; transform: translateY(-50%); }
|
||||
.layout-cell-side-trigger { background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; }
|
||||
.layout-cell-side-trigger:hover { background: #1e40af; }
|
||||
.layout-cell-side-menu { display: none; position: absolute; gap: 4px; background: #111827; border-radius: 4px; padding: 4px; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.25); }
|
||||
.layout-cell-side:hover .layout-cell-side-menu { display: flex; }
|
||||
.layout-cell-side-top .layout-cell-side-menu { bottom: 28px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-side-bottom .layout-cell-side-menu { top: 28px; left: 50%; transform: translateX(-50%); }
|
||||
.layout-cell-side-left .layout-cell-side-menu { right: 28px; top: 50%; transform: translateY(-50%); flex-direction: column; }
|
||||
.layout-cell-side-right .layout-cell-side-menu { left: 28px; top: 50%; transform: translateY(-50%); flex-direction: column; }
|
||||
.layout-cell-side-menu button { background: #fff; color: #111827; border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem; line-height: 1; padding: 5px 7px; white-space: nowrap; }
|
||||
.layout-cell-side-menu button:hover { background: #dbeafe; color: #1e40af; }
|
||||
.layout-cell-delete { position: absolute; top: 4px; right: 4px; background: rgba(220, 38, 38, 0.9); color: #fff; border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; opacity: 0; transition: opacity 0.2s; z-index: 2; }
|
||||
.layout-cell:hover .layout-cell-delete { opacity: 1; }
|
||||
.layout-cell-resize { position: absolute; bottom: 4px; right: 4px; display: flex; gap: 2px; opacity: 0; transition: opacity 0.2s; z-index: 2; }
|
||||
.layout-cell:hover .layout-cell-resize { opacity: 1; }
|
||||
.layout-cell-resize button { background: rgba(37, 99, 235, 0.85); color: #fff; border: none; min-width: 24px; height: 18px; border-radius: 3px; cursor: pointer; font-size: 0.65rem; line-height: 1; padding: 0 4px; font-weight: 700; }
|
||||
.layout-cell-resize button:hover { background: #1e40af; }
|
||||
.layout-cell-edit-form { width: 100%; }
|
||||
.layout-cell-edit-form .form-group { margin-bottom: 0.5rem; }
|
||||
.layout-cell-edit-form label { font-size: 0.75rem; font-weight: 600; display: block; margin-bottom: 0.15rem; }
|
||||
|
|
@ -1585,55 +1590,30 @@ export function renderCell(
|
|||
{(["top", "right", "bottom", "left"] as const).map((dir) => {
|
||||
const directionParam = dir === "top" ? "above" : dir;
|
||||
return (
|
||||
<div class={`layout-cell-side layout-cell-side-${dir}`} {...{ "onclick": "event.stopPropagation()" }}>
|
||||
<button type="button" class="layout-cell-side-trigger" title={`Actions ${dir}`}>+</button>
|
||||
<div class="layout-cell-side-menu">
|
||||
<button
|
||||
type="button"
|
||||
title={`Expand ${dir}`}
|
||||
hx-post={resizeUrl}
|
||||
hx-vals={JSON.stringify({ direction: directionParam })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
>Expand</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`layout-cell-add layout-cell-add-${dir}`}
|
||||
title={`Add cell ${dir}`}
|
||||
hx-post={addUrl}
|
||||
hx-vals={JSON.stringify({ after_cell_id: c.id, direction: directionParam })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
{...{ "onclick": "event.stopPropagation()" }}
|
||||
>+</button>
|
||||
>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* resize buttons */}
|
||||
<div class="layout-cell-resize" {...{ "onclick": "event.stopPropagation()" }}>
|
||||
<button
|
||||
type="button"
|
||||
title="Increase width"
|
||||
hx-post={resizeUrl}
|
||||
hx-vals={JSON.stringify({ dim: "col_span", delta: 1 })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
>+W</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Decrease width"
|
||||
hx-post={resizeUrl}
|
||||
hx-vals={JSON.stringify({ dim: "col_span", delta: -1 })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
>-W</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Increase height"
|
||||
hx-post={resizeUrl}
|
||||
hx-vals={JSON.stringify({ dim: "row_span", delta: 1 })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
>+H</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Decrease height"
|
||||
hx-post={resizeUrl}
|
||||
hx-vals={JSON.stringify({ dim: "row_span", delta: -1 })}
|
||||
hx-target="#layout-grid"
|
||||
hx-swap="innerHTML"
|
||||
>-H</button>
|
||||
</div>
|
||||
|
||||
{/* delete button */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1781,9 +1761,9 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
|
|||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Layout Builder</h2>
|
||||
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
|
||||
Hover a cell for <strong>+</strong> (add neighbour), resize handles
|
||||
(<strong>+W -W +H -H</strong>) and <strong>×</strong> (delete).
|
||||
Click a cell to edit content in-place.
|
||||
Hover a side <strong>+</strong> to add a neighbour or expand the cell.
|
||||
Expanding pushes cells in that direction out of the way. Click a cell
|
||||
to edit content in-place.
|
||||
</p>
|
||||
<div id="layout-grid">
|
||||
{renderGrid(l.id, cells, props.entities, props.cameras)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue