"use client"; import type { CSSProperties, KeyboardEvent, PointerEvent } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box } from "@mantine/core"; import combineClasses from "clsx"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types"; import { getCollapsedDisplayLayout, getEditableCanvasAttributes, getGridRowCountForVisualHeight, getLayoutRowCount, getLogicalGridSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; import { SectionContent } from "../content"; import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse"; import { SectionProvider } from "../section-context"; import { useSectionItems } from "../use-section-items"; import { useBoardGridPortalHost } from "./grid-portal-host"; import classes from "./section-grid.module.css"; interface SectionGridProps { section: Exclude | ContainerSectionItem; columnCount: number; requestedRowCount?: number; viewportRowCountOverride?: number; label: string; railPlacement?: "main" | "left" | "right"; className?: string; } export const SectionGrid = ({ section, columnCount, requestedRowCount = 0, viewportRowCountOverride, label, railPlacement = "main", className, }: SectionGridProps) => { const [isEditMode] = useEditMode(); const canvasScale = useBoardCanvasScale(); const editorRuntimeStatus = useGridEditorRuntimeStatus(); const editorRegistry = useGridEditorRegistry(); const editorHostRef = useRef(null); const [entryElementStore] = useState(createGridEntryElementStore); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); const { items, innerSections } = useSectionItems(section.id); const { announce, integrations } = useBoardGridPortalHost(); const collapsedSectionIds = useCollapsedSectionIds(); const expandSectionsForEditing = useExpandSectionsForEditing(); const minimumBySectionId = useMemo(() => { const minimumSizes = getContainerMinimumSizes(board, currentLayoutId); return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)])); }, [board, currentLayoutId, innerSections]); const placements = useMemo( () => [...items, ...innerSections].map((item): SectionGridPlacement => { const minimum = item.type === "section" ? minimumBySectionId.get(item.id) : undefined; const isNonScrollableContainer = item.type === "section" && !item.options.scrollable; const height = isNonScrollableContainer && minimum ? Math.max(item.height, minimum.height) : item.height; return normalizeGridPlacement( { id: item.id, type: item.type, x: item.xOffset, y: item.yOffset, w: item.width, h: height, minW: minimum?.width, minH: minimum?.height, }, columnCount, ); }), [columnCount, innerSections, items, minimumBySectionId], ); const collapsibleSectionIds = useMemo( () => new Set( innerSections.filter((innerSection) => innerSection.options.collapsible).map((innerSection) => innerSection.id), ), [innerSections], ); const directCollapsedIds = useMemo( () => new Set( placements .filter( (placement) => placement.type === "section" && collapsedSectionIds.has(placement.id) && collapsibleSectionIds.has(placement.id), ) .map((placement) => placement.id), ), [collapsedSectionIds, collapsibleSectionIds, placements], ); const displayPlacements = useMemo(() => { if (directCollapsedIds.size === 0) return placements; return getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: directCollapsedIds, }); }, [columnCount, directCollapsedIds, placements]); const placementById = useMemo( () => new Map(displayPlacements.map((placement) => [placement.id, placement])), [displayPlacements], ); const displayedItems = useMemo( () => items.map((item) => withPlacement(item, placementById.get(item.id))), [items, placementById], ); const displayedInnerSections = useMemo( () => innerSections.map((item) => withPlacement(item, placementById.get(item.id))), [innerSections, placementById], ); const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null; let placementMaxRowCount = maxRowCount; if (railPlacement !== "main") { placementMaxRowCount = minimumViewportRowCount; } // A scrollable container isn't forced to grow with its content - it scrolls internally instead // 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 = 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 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; // 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; // 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); const canvasAttributes = isEditMode && !isInteractionDisabled ? getEditableCanvasAttributes({ label, columnCount, rowCount }) : getReadonlyCanvasAttributes({ label }); const editorClassName = combineClasses("board-grid-editor", classes.editorGrid); const expandCollapsedSectionsForPointerEdit = (event: PointerEvent) => { if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return; const target = event.target; if (!(target instanceof Element)) return; const entry = target.closest('[data-editor-grid-entry="true"]'); if (!entry || !event.currentTarget.contains(entry)) return; if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return; expandSectionsForEditing(directCollapsedIds); }; const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent) => { if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return; const target = event.target; if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return; expandSectionsForEditing(directCollapsedIds); }; useLayoutEffect(() => { const host = editorHostRef.current; if (!host) return; return editorRegistry.register({ host, disabled: isInteractionDisabled, sectionId: section.id, section, items: displayedItems, innerSections: displayedInnerSections, columnCount, rowCount, maxRowCount, placementMaxRowCount, placements: displayPlacements, transactionPlacements: placements, className: editorClassName, entryElementStore, }); }, [ columnCount, displayPlacements, displayedInnerSections, displayedItems, editorClassName, editorRegistry, entryElementStore, isInteractionDisabled, maxRowCount, placementMaxRowCount, placements, rowCount, section, ]); const viewportRef = useRef(null); const previousItemIdsRef = useRef | null>(null); useEffect(() => { const currentIds = new Set([...items, ...innerSections].map((item) => item.id)); const previousIds = previousItemIdsRef.current; const hasNewItem = previousIds !== null && [...currentIds].some((id) => !previousIds.has(id)); previousItemIdsRef.current = currentIds; if (hasNewItem && isScrollableContainer) { viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" }); } }, [innerSections, isScrollableContainer, items]); return (
); }; // 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"]); const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => { const [visualHeight, setVisualHeight] = useState(0); useEffect(() => { if (!enabled) return; const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight); update(); window.addEventListener("resize", update, { passive: true }); window.visualViewport?.addEventListener("resize", update, { passive: true }); return () => { window.removeEventListener("resize", update); window.visualViewport?.removeEventListener("resize", update); }; }, [enabled]); return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0; }; const containerMinimumSizeCache = new WeakMap< ReturnType, Map> >(); const getContainerMinimumSizes = (board: ReturnType, layoutId: string) => { const cachedByLayout = containerMinimumSizeCache.get(board); const cached = cachedByLayout?.get(layoutId); if (cached) return cached; const directItemsBySectionId = new Map(); for (const item of board.items) { const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); if (!layout) continue; const entries = directItemsBySectionId.get(layout.sectionId) ?? []; entries.push(layout); directItemsBySectionId.set(layout.sectionId, entries); } const directSectionsBySectionId = new Map(); for (const section of board.sections) { if (section.kind !== "container") continue; const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); if (!layout) continue; const entries = directSectionsBySectionId.get(layout.parentSectionId) ?? []; entries.push({ id: section.id, placement: layout }); directSectionsBySectionId.set(layout.parentSectionId, entries); } const minimumBySectionId = new Map(); const visiting = new Set(); const resolve = (sectionId: string): { width: number; height: number } => { const existing = minimumBySectionId.get(sectionId); if (existing) return existing; if (visiting.has(sectionId)) return { width: 1, height: 1 }; visiting.add(sectionId); const section = board.sections.find((candidate) => candidate.id === sectionId); // A scrollable container isn't forced to grow with its content - it scrolls internally instead, // so it shouldn't have a content-derived height floor imposed by its parent's grid. const isScrollable = section?.kind === "container" && section.options.scrollable; const itemBounds = directItemsBySectionId.get(sectionId) ?? []; const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => { const minimum = resolve(id); return { ...placement, width: Math.max(placement.width, minimum.width), height: Math.max(placement.height, minimum.height), }; }); const children = [...itemBounds, ...sectionBounds]; const minimum = { width: Math.max(1, ...children.map((child) => child.xOffset + child.width)), height: isScrollable ? 1 : Math.max(1, ...children.map((child) => child.yOffset + child.height)), }; visiting.delete(sectionId); minimumBySectionId.set(sectionId, minimum); return minimum; }; for (const section of board.sections) { if (section.kind === "container") resolve(section.id); } const nextByLayout = cachedByLayout ?? new Map(); nextByLayout.set(layoutId, minimumBySectionId); containerMinimumSizeCache.set(board, nextByLayout); return minimumBySectionId; }; interface PlacementBounds { xOffset: number; yOffset: number; width: number; height: number; } const withPlacement = < TItem extends { id: string; xOffset: number; yOffset: number; width: number; height: number; }, >( item: TItem, placement: SectionGridPlacement | undefined, ): TItem => { if (!placement) return item; return { ...item, xOffset: placement.x, yOffset: placement.y, width: placement.w, height: placement.h, }; };