From 61d3fd5f0818f0822e50aa325307a3b23c7104f4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 9 Sep 2026 15:04:27 +0200 Subject: [PATCH] fix(board): restore container grid geometry (#6808) --- apps/docs/docs/management/boards/index.mdx | 5 ++ .../board/sections/grid/grid-editor.css | 17 +++---- .../board/sections/grid/grid-editor.tsx | 10 +++- .../sections/grid/section-grid.module.css | 6 ++- .../board/sections/grid/section-grid.tsx | 47 ++++--------------- .../sections/grid/use-grid-layout-actions.ts | 3 +- .../components/board/sections/item.module.css | 3 ++ 7 files changed, 37 insertions(+), 54 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 5b51ef96e..9c1f75a03 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -36,6 +36,11 @@ current Base layout. A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action, border color, and CSS classes. +Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item +will remain when released. +Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their +previewed positions without a movement animation and stay there when you release the pointer. + A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page scrolls and store their contents separately for each layout. diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 4b7e47054..a50350c4c 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -1,9 +1,11 @@ .board-grid-editor { position: absolute; - inset: 0 auto auto 0; + inset: 0 0 auto; + margin: 0 auto; overflow: visible; border-radius: var(--mantine-radius-md); pointer-events: none; + zoom: var(--board-grid-content-scale, 1); } .board-grid-entry { @@ -20,14 +22,7 @@ width: var(--board-grid-preview-width) !important; height: var(--board-grid-preview-height) !important; translate: var(--board-grid-preview-x) var(--board-grid-preview-y); - will-change: translate; -} - -.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) { - transition: - translate 100ms ease, - width 100ms ease, - height 100ms ease; + transition: none; } .board-grid-entry[data-dnd-active="true"], @@ -109,8 +104,8 @@ zoom: var(--board-grid-overlay-content-zoom, 1); } -body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"], -body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"], +/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */ +body[data-board-grid-interacting] .board-grid-entry, body[data-board-grid-interacting] .board-grid-placeholder { transition: none; } 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 32245dfe6..a30b77449 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -415,7 +415,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { sectionId: grid.id, placements: grid.placements, })); - commitSectionGrids(snapshots); + const hasChanges = commitSectionGrids(snapshots); + if (!hasChanges) { + clearInteraction(); + return; + } const placement = state.grids .flatMap((grid) => grid.placements) @@ -429,7 +433,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { )} ${placement.h}`, ); } - clearInteraction(); + // Cache subscribers receive the committed board asynchronously. Keep the preview until + // the board-change layout effect clears it, so displaced items never flash at their old positions. + activeRef.current = null; }, [announce, clearInteraction, commitSectionGrids, currentLayoutId, t], ); 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 df8a2c3ea..b722c4d59 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 @@ -46,7 +46,6 @@ .editorGrid { position: absolute; - inset: 0 auto auto 0; } .editorPortalHost { @@ -63,6 +62,11 @@ overflow: visible; } +/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */ +.staticItem[data-type="section"] > .contentMount { + inset: 0; +} + .staticItem[data-type="item"] > .contentMount { inset: calc(10px * var(--board-canvas-inverse-scale, 1)); } 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 d606e30db..b1a04a56d 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -143,40 +143,15 @@ export const SectionGrid = ({ const isScrollableContainer = section.kind === "container" && section.options.scrollable; const viewportRowCount = viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount); - // A container's own visible card is inset from its allocated board cell by the board's - // standard per-item gap (see the base, non-container `.staticItem[data-type="item"] > - // .contentMount` rule in section-grid.module.css - a container is placed as an "item" like - // any widget, so it gets that same gap). A widget's content just fills whatever size its - // card ends up being, but this SectionGrid instead computes its own fixed pixel size from - // the same column/row counts used to allocate the *outer*, uninset cell - so without - // subtracting that gap back out here, a container's inner grid renders larger than its own - // card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset. const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; - const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0; - // A collapsible container's toggle bar (see the `containerToggle` Button in - // container-section.tsx) is an absolutely positioned overlay sitting on top of this grid. - // Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated - // by --mantine-scale to render at a constant physical size regardless of board zoom (see - // scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above, - // not like the grid's own un-compensated logical-pixel math, so it needs the same - // effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on - // a zoomed-out board and leave the header overlapping the content. Without reserving it at - // all, the header bar covers the first row of the container's content instead of sitting - // above it. Only the vertical axis is affected - the toggle spans the full width already. + // The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels. + // Convert that height so the label reserves the same space at every board scale. const collapsibleHeaderInset = section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0; - const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset; - const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset); - // Items are positioned in a coordinate space sized to the *un-inset* column/row count - // (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are - // deliberately smaller by outerCardInset to match the container's actual visible card size. - // Left alone, that mismatch means the items - not just the grid's own box - spill past the - // card's right/bottom edge by exactly that inset. Scale the grid's content down by the same - // ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one - // level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is - // corrected to compensate so icon/text sizing inside the container isn't affected. const fullGridWidth = getLogicalGridSize(columnCount); const fullGridHeight = getLogicalGridSize(rowCount); + const logicalWidth = fullGridWidth; + const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset); // For a scrollable container, rowCount covers *all* content rows, not just the visible card - // fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content // grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible @@ -184,16 +159,9 @@ export const SectionGrid = ({ // describe the real visible card size in both cases (they equal the non-scrollable values when // rowCount and viewportRowCount are the same), so use those instead. const fullViewportHeight = getLogicalGridSize(viewportRowCount); - // A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale - // is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies - // by, so a non-uniform stretch can never be captured by it correctly. That means one axis can - // be left with a small amount of unfilled slack when width/height need different ratios to - // exactly fit - centered below so it's even on both sides rather than left-aligned. - // logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container - // on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can - // exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach - // zero or negative - combinedUiScale divides by this value, and this value is also used as - // zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise. + // A collapsible container reserves a fixed-height label above the square-cell grid. Keep the + // nested content uniformly scaled within the remaining height so labels and icons retain their + // proportions; ordinary containers use the original unscaled, edge-aligned grid geometry. const containerContentScale = section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0 ? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1)) @@ -302,6 +270,7 @@ export const SectionGrid = ({ height: `var(--board-grid-drag-height, ${viewportHeight}px)`, marginTop: collapsibleHeaderInset || undefined, "--board-item-radius": `var(--mantine-radius-${board.itemRadius})`, + "--board-grid-content-scale": containerContentScale, } as CSSProperties } data-section-id={section.id} diff --git a/apps/nextjs/src/components/board/sections/grid/use-grid-layout-actions.ts b/apps/nextjs/src/components/board/sections/grid/use-grid-layout-actions.ts index 2dd20fd71..59075087a 100644 --- a/apps/nextjs/src/components/board/sections/grid/use-grid-layout-actions.ts +++ b/apps/nextjs/src/components/board/sections/grid/use-grid-layout-actions.ts @@ -33,8 +33,8 @@ export const useGridLayoutActions = () => { placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const), ), ); + let hasChanges = false; updateBoard((previous) => { - let hasChanges = false; const items = previous.items.map((item) => { const layouts = item.layouts.map((layout) => { const resolved = byId.get(`${layout.layoutId}:${item.id}`); @@ -97,6 +97,7 @@ export const useGridLayoutActions = () => { return hasChanges ? { ...previous, items, sections } : previous; }); + return hasChanges; }, [updateBoard], ); diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css index e8b9e4d8f..8fac711ce 100644 --- a/apps/nextjs/src/components/board/sections/item.module.css +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -21,6 +21,9 @@ --border-color: rgb( from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1) ); + /* Keep the border visual without letting it shrink the square-cell grid's content box. */ + border-width: 0; + box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color); } .itemCard:global(.iframe-wrapper) {