diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index dff3d3f7c..5c936aed2 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -11,6 +11,7 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types"; import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout"; import { SectionGrid } from "./grid/section-grid"; import { useSectionCollapse } from "./section-collapse"; +import { useSectionContext } from "./section-context"; import { useOpenSectionApps } from "./use-open-section-apps"; import classes from "./item.module.css"; @@ -25,6 +26,7 @@ interface Props { export const BoardContainerSection = ({ section }: Props) => { const board = useRequiredBoard(); + const { section: parentSection } = useSectionContext(); const [isEditMode] = useEditMode(); const t = useI18n("section.container"); const tSection = useI18n("section"); @@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => { }); const label = options.title.trim() || t("untitled"); const contentId = `board-container-${section.id}-content`; - const labelLeft = 8; - const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8; + let labelLeft = 8; + let labelRight = 8; + if (isEditMode) { + // Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap. + labelLeft = 36; + if (parentSection.kind === "container") labelLeft = 68; + } else if (options.showOpenAll) { + labelRight = 40; + } + // Expanded controls sit on the border like the ordinary label, without reserving a header row. + const toggleLayout = isVisuallyCollapsed + ? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" } + : { + top: "calc(var(--mantine-spacing-xs) * -1)", + left: labelLeft, + w: "auto", + h: 20, + maw: `calc(100% - ${labelLeft + labelRight}px)`, + }; + const toggleIcon = isVisuallyCollapsed ? : ; return ( @@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => { )} {!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && ( 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 a50350c4c..14b711bd8 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -133,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent); } +.board-grid-placeholder[data-grid-placeholder-type="section"] { + clip-path: none; + background: none; + border: 0; +} + +.board-grid-placeholder[data-grid-placeholder-type="section"]::after { + position: absolute; + inset: var(--board-container-inset, 0px); + content: ""; + background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent); + border: 2px dashed var(--mantine-primary-color-filled); + border-radius: inherit; +} + .board-grid-resize-outline { z-index: 1002; clip-path: inset(var(--board-grid-item-inset, 0px)); 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 20881a97f..abcccb35e 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 @@ -136,6 +136,7 @@ export const GridPreviewLayer = ({ style={getLogicalItemStyle(targetPlacement)} data-grid-placeholder-for={interaction?.activeId} data-grid-placeholder-mode={interaction?.mode} + data-grid-placeholder-type={targetPlacement.type} data-grid-x={targetPlacement.x} data-grid-y={targetPlacement.y} data-grid-w={targetPlacement.w} 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 b722c4d59..c750f0cf7 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 @@ -10,9 +10,9 @@ while growing during a drag happening anywhere else on the board - any container (not just a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits inside that section's DOM tree, so the stray value cascades straight through and silently - defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including - its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely - unset, not merely inherited. Reset it here so every container can never inherit a stray + defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only + kicks in when the property is entirely unset, not merely inherited. Reset it here so every + container can never inherit a stray value from anywhere above it, no matter what any ancestor's own drag state is doing. https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */ .viewport[data-section-kind="container"] { @@ -62,9 +62,9 @@ overflow: visible; } -/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */ +/* SectionGrid fits its content to the same inset in both view and edit mode. */ .staticItem[data-type="section"] > .contentMount { - inset: 0; + inset: var(--board-container-inset, 0px); } .staticItem[data-type="item"] > .contentMount { 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 b1a04a56d..93ccf371e 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties, KeyboardEvent, PointerEvent } from "react"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box } from "@mantine/core"; import combineClasses from "clsx"; @@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items"; import { useBoardGridPortalHost } from "./grid-portal-host"; import classes from "./section-grid.module.css"; +const GridContentScaleContext = createContext(1); +const CONTAINER_CARD_INSET = 5; + interface SectionGridProps { section: Exclude | ContainerSectionItem; columnCount: number; @@ -50,6 +53,7 @@ export const SectionGrid = ({ }: SectionGridProps) => { const [isEditMode] = useEditMode(); const canvasScale = useBoardCanvasScale(); + const parentContentScale = useContext(GridContentScaleContext); const editorRuntimeStatus = useGridEditorRuntimeStatus(); const editorRegistry = useGridEditorRegistry(); const editorHostRef = useRef(null); @@ -143,36 +147,37 @@ export const SectionGrid = ({ const isScrollableContainer = section.kind === "container" && section.options.scrollable; const viewportRowCount = viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount); - const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; - // 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 parentScale = canvasScale * parentContentScale; + const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1; + // Match the card's inset without changing its persisted grid footprint. Include ancestor + // container zoom so equally sized nested containers keep distinct borders at every depth. + let outerCardInset = 0; + if (section.kind === "container") { + outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale; + } 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 - // viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount - // 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 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)) - : 1; + const logicalWidth = Math.max(1, fullGridWidth - outerCardInset); + const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset); + // Fit square cells into the inset card. Scrollable grids use the visible row count, + // not their full content height. Collapse controls never change this geometry. + let containerContentScale = 1; + if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) { + containerContentScale = Math.max( + 0.01, + Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1), + ); + } + const contentScale = parentContentScale * containerContentScale; + const effectiveContentScale = effectiveCanvasScale * containerContentScale; // Compute the combined value in JS rather than a CSS calc() referencing the existing // --board-canvas-ui-scale: custom properties declared on the *same* element don't have a // sequential/temporal order the way normal variables do, so any calc() on this element that // both reads and writes --board-canvas-ui-scale (even indirectly, through another property) // is a circular reference - CSS invalidates the whole group rather than using "the old value", // silently breaking every icon/text/custom-CSS size that compensates off it for descendants. - const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale; + const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale; // 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); @@ -268,9 +273,9 @@ export const SectionGrid = ({ { width: logicalWidth, height: `var(--board-grid-drag-height, ${viewportHeight}px)`, - marginTop: collapsibleHeaderInset || undefined, "--board-item-radius": `var(--mantine-radius-${board.itemRadius})`, "--board-grid-content-scale": containerContentScale, + "--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`, } as CSSProperties } data-section-id={section.id} @@ -289,14 +294,17 @@ export const SectionGrid = ({ height: fullGridHeight, zoom: containerContentScale, margin: containerContentScale < 1 ? "0 auto" : undefined, - ...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}), + "--board-canvas-inverse-scale": 1 / effectiveContentScale, + "--board-canvas-ui-scale": combinedUiScale, } as CSSProperties } data-grid-section-id={section.id} data-kind={section.kind} data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined} > - + + +
@@ -304,9 +312,6 @@ export const SectionGrid = ({ ); }; -// Matches the collapsible container toggle's h={24} in container-section.tsx. -const CONTAINER_HEADER_HEIGHT = 24; - const INTERACTIVE_GRID_SELECTOR = 'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]'; const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);