From e022c3f73519974d3e30f8c99ce976901ea2745f Mon Sep 17 00:00:00 2001 From: ajnart Date: Sun, 23 Aug 2026 00:47:20 +0200 Subject: [PATCH] fix: match legacy widget grid dimensions --- .../app/[locale]/boards/(content)/_client.tsx | 10 +++++----- .../src/components/board/layout/README.md | 17 ++++++++--------- .../src/components/board/layout/board-lanes.ts | 4 ++-- .../src/components/board/layout/geometry.ts | 17 ++++++++++++++--- .../board/sections/grid/dnd/coordinates.ts | 4 ++-- .../board/sections/grid/grid-editor.tsx | 10 +++++----- .../sections/grid/grid-preview-geometry.ts | 6 +++--- .../board/sections/grid/grid-preview-layer.tsx | 6 +++--- .../board/sections/grid/grid-resize-outline.ts | 6 +++--- .../board/sections/grid/section-grid.module.css | 4 ++++ .../board/sections/grid/section-grid.tsx | 8 ++++---- 11 files changed, 53 insertions(+), 39 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 7c9ab7c4e..395178901 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -16,7 +16,7 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, - getLogicalTrackSize, + getLogicalGridSize, LOGICAL_GRID_GAP, getRootSectionForLane, } from "~/components/board/layout"; @@ -61,16 +61,16 @@ export const ClientBoard = () => { const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right"); const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount] .filter((columnCount) => columnCount > 0) - .map(getLogicalTrackSize); + .map(getLogicalGridSize); const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId); const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const gridTemplateColumns = [ - leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null, - `${getLogicalTrackSize(mainColumnCount)}px`, - rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null, + leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null, + `${getLogicalGridSize(mainColumnCount)}px`, + rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null, ] .filter((value) => value !== null) .join(" "); diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 6eb9c412f..54358e498 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -2,17 +2,16 @@ The board uses fixed logical units: -- one `1 × 1` item is exactly `200 × 200` logical pixels; -- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch; -- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`); +- one grid track has a `212 × 212` logical-pixel footprint; +- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter; +- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset); - the canvas is zoomed once with one uniform scale derived from its available width. -Items keep the same logical dimensions at every viewport. The viewport reserves -the painted size while the inner canvas remains at its logical dimensions, so a -widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay -stable. The visual canvas always fits the available width, every root lane -keeps at least one viewport of height, and edit-mode previews extend it downward -as needed. +Grid footprints keep the same logical dimensions at every viewport. Widget cards +use a scale-compensated inset so their painted size and content measurements +match the legacy responsive grid. The visual canvas always fits the available +width, every root lane keeps at least one viewport of height, and edit-mode +previews extend it downward as needed. The read-only renderer should: diff --git a/apps/nextjs/src/components/board/layout/board-lanes.ts b/apps/nextjs/src/components/board/layout/board-lanes.ts index b7345fa10..8e7256fa9 100644 --- a/apps/nextjs/src/components/board/layout/board-lanes.ts +++ b/apps/nextjs/src/components/board/layout/board-lanes.ts @@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import type { Board, EmptySection } from "~/app/[locale]/boards/_types"; -import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry"; +import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry"; import { getCollapsedDisplayLayout } from "./reflow"; import type { GridPlacement } from "./types"; @@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => return [getLayoutRowCount(displayPlacements)]; }); - return getLogicalTrackSize(Math.max(0, ...rowCounts)); + return getLogicalGridSize(Math.max(0, ...rowCounts)); }; diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts index 449e74d4d..558b168b7 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -10,12 +10,23 @@ export const getLogicalTrackSize = (trackCount: number) => { return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP; }; +/** + * Returns the complete footprint occupied by grid tracks. Cards are inset + * inside this footprint so their visual gutters stay fixed while the canvas + * is scaled. + */ +export const getLogicalGridSize = (trackCount: number) => { + const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount")); + + return normalizedTrackCount * LOGICAL_GRID_PITCH; +}; + export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({ position: "absolute", left: x * LOGICAL_GRID_PITCH, top: y * LOGICAL_GRID_PITCH, - width: getLogicalTrackSize(w), - height: getLogicalTrackSize(h), + width: getLogicalGridSize(w), + height: getLogicalGridSize(h), }); export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => @@ -25,7 +36,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; - return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH)); + return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH)); }; export const normalizeGridPlacement = ( diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts index 79349d94c..bc059c3e6 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; import type { GridResizeDirection } from "./types"; @@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({ target: RectangleLike; columnCount: number; }) => { - const logicalWidth = getLogicalTrackSize(columnCount); + const logicalWidth = getLogicalGridSize(columnCount); const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0; if (!Number.isFinite(visualScale) || visualScale <= 0) return null; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 4e8051c76..32245dfe6 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { getWidgetName } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { beginGridTransaction, cancelGridTransaction, @@ -792,8 +792,8 @@ export default function GridEditor({ data-grid-depth={depth} style={ { - width: getLogicalTrackSize(columnCount), - height: getLogicalTrackSize(rowCount), + width: getLogicalGridSize(columnCount), + height: getLogicalGridSize(rowCount), "--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`, } as CSSProperties } @@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid if (!element) return null; const rectangle = element.getBoundingClientRect(); const scale = { - x: rectangle.width / getLogicalTrackSize(placement.w), - y: rectangle.height / getLogicalTrackSize(placement.h), + x: rectangle.width / getLogicalGridSize(placement.w), + y: rectangle.height / getLogicalGridSize(placement.h), }; return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null; }; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts b/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts index af340ba78..d9d16b218 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts +++ b/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; export interface GridPreviewDomState { @@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = ( const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => { element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`); element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`); - element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`); - element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`); + element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`); + element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`); element.setAttribute("data-grid-preview", "true"); setPlacementAttributes(element, preview); }; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx index d7397b6b2..20881a97f 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx @@ -3,7 +3,7 @@ import type { RefObject } from "react"; import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; -import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout"; +import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout"; import type { TransactionalGridState } from "./dnd"; import type { GridInteractionStore } from "./grid-interaction-store"; import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry"; @@ -87,7 +87,7 @@ export const GridPreviewLayer = ({ } else { delete grid.dataset.dndPreviewRevision; } - grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`; + grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`; const viewport = grid.parentElement; if (viewport?.hasAttribute("data-section-id")) { @@ -98,7 +98,7 @@ export const GridPreviewLayer = ({ // stray value would silently blow the cap open for the rest of the session, well after any // drag ended, since nothing else would ever clear it back out. if (maxRowCount === null) { - viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`); + viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`); } else { viewport.style.removeProperty("--board-grid-drag-height"); } diff --git a/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts b/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts index 547dfe69d..8bdcf63a8 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts +++ b/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; interface ResizeOutlinePreview { @@ -31,8 +31,8 @@ export const createGridResizeOutlineController = ( outline.style.position = "absolute"; outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`; outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`; - outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`; - outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`; + outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`; + outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`; outline.dataset.gridResizeValid = String(preview.valid); }; diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css index c08746422..d24f5d776 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css @@ -50,6 +50,10 @@ overflow: visible; } +.staticItem[data-type="item"] > .contentMount { + inset: calc(10px * var(--board-canvas-inverse-scale, 1)); +} + .staticItem:has([data-board-container-label]) { z-index: 2; } diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index cec4616c9..fc4642641 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -14,7 +14,7 @@ import { getEditableCanvasAttributes, getGridRowCountForVisualHeight, getLayoutRowCount, - getLogicalTrackSize, + getLogicalGridSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; @@ -138,9 +138,9 @@ export const SectionGrid = ({ // of expanding to fit every widget, so its viewport height is capped independently of rowCount. const isScrollableContainer = section.kind === "container" && section.options.scrollable; const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount; - const logicalWidth = getLogicalTrackSize(columnCount); - const logicalHeight = getLogicalTrackSize(rowCount); - const viewportHeight = getLogicalTrackSize(viewportRowCount); + const logicalWidth = getLogicalGridSize(columnCount); + const logicalHeight = getLogicalGridSize(rowCount); + const viewportHeight = getLogicalGridSize(viewportRowCount); // A collapsed container's compact coordinates are display-only. Its own // nested grid stays inactive until an explicit edit interaction expands it. const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);