Compare commits

...
Author SHA1 Message Date
Thomas Camlong 59b6f08a36 fix(board): harden grid edit transitions 2026-08-21 15:49:10 +02:00
Thomas Camlong 5cd627d66c perf(board): stabilize edit mode layout 2026-08-21 15:23:18 +02:00
20 changed files with 616 additions and 680 deletions

No files matched your search

@@ -20,7 +20,7 @@ import {
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary"; import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host"; import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse"; import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context"; import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
@@ -80,7 +80,6 @@ export const ClientBoard = () => {
label={board.name} label={board.name}
> >
<BoardGridEditorBoundary key={currentLayoutId}> <BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}> <div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && ( {leftColumnCount > 0 && leftSection && (
<aside <aside
@@ -25,12 +25,14 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context"; import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel"; import { redirectShiftWheel } from "../advanced-focus/wheel";
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
import classes from "../sections/item.module.css"; import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
@@ -103,9 +105,11 @@ const WidgetDefinitionLoadError = ({
); );
export const BoardItemContent = ({ item }: BoardItemContentProps) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>(); const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
const widgetStateRef = useRef<Record<string, unknown> | null>(null); const { getEntryRuntime } = useBoardGridPortalHost();
const widgetRuntimeRef = useRef(createWidgetRuntimeState()); const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
const width = measuredWidth || getLogicalTrackSize(item.width);
const height = measuredHeight || getLogicalTrackSize(item.height);
return ( return (
<ErrorBoundary <ErrorBoundary
@@ -134,6 +138,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
); );
}; };
const createBoardItemRuntime = () => ({
widgetStateRef: { current: null } as MutableRefObject<Record<string, unknown> | null>,
widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef,
});
interface LoadedBoardItemContentProps { interface LoadedBoardItemContentProps {
item: SectionItem; item: SectionItem;
width: number; width: number;
@@ -62,7 +62,7 @@ export const BoardContainerSection = ({ section }: Props) => {
radius={board.itemRadius} radius={board.itemRadius}
p={0} p={0}
> >
{options.collapsible && !isEditMode && ( {options.collapsible && (
<Button <Button
className={classes.containerToggle} className={classes.containerToggle}
pos="absolute" pos="absolute"
@@ -71,7 +71,7 @@ export const BoardContainerSection = ({ section }: Props) => {
w="100%" w="100%"
h={isVisuallyCollapsed ? "100%" : 24} h={isVisuallyCollapsed ? "100%" : 24}
px={12} px={12}
pe={options.showOpenAll ? 40 : 12} pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
radius="sm" radius="sm"
variant="default" variant="default"
justify={options.showLabel ? "flex-start" : "center"} justify={options.showLabel ? "flex-start" : "center"}
@@ -101,7 +101,7 @@ export const BoardContainerSection = ({ section }: Props) => {
)} )}
</Button> </Button>
)} )}
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && ( {!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
<Badge <Badge
pos="absolute" pos="absolute"
top={-24} top={-24}
@@ -2,13 +2,16 @@
import type { ComponentType, PropsWithChildren } from "react"; import type { ComponentType, PropsWithChildren } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { loadGridEditorAsync } from "./grid-editor-loader"; import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime"; import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>; type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
type LoadedGridEditorModule = Awaited<ReturnType<typeof loadGridEditorAsync>>;
/** /**
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing * Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
@@ -16,17 +19,20 @@ type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
*/ */
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => { export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null); const [EditorModule, setEditorModule] = useState<LoadedGridEditorModule | null>(
getLoadedGridEditorModule() ?? null,
);
const [hasLoadError, setHasLoadError] = useState(false); const [hasLoadError, setHasLoadError] = useState(false);
const resolvedEditorModule = EditorModule ?? getLoadedGridEditorModule() ?? null;
useEffect(() => { useEffect(() => {
if (!isEditMode || Provider) return; if (!isEditMode || resolvedEditorModule) return;
let cancelled = false; let cancelled = false;
setHasLoadError(false); setHasLoadError(false);
void loadGridEditorAsync() void loadGridEditorAsync()
.then((module) => { .then((module) => {
if (!cancelled) setProvider(() => module.BoardGridEditorProvider); if (!cancelled) setEditorModule(module);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setHasLoadError(true); if (!cancelled) setHasLoadError(true);
@@ -35,23 +41,34 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [Provider, isEditMode]); }, [isEditMode, resolvedEditorModule]);
if (!isEditMode) { const runtimeStatus = !isEditMode
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>; ? "idle"
} : hasLoadError
? "error"
if (hasLoadError) { : resolvedEditorModule
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>; ? "ready"
} : "loading";
const Provider: BoardGridEditorProviderComponent | null = resolvedEditorModule?.BoardGridEditorProvider ?? null;
if (!Provider) { const GridEditor = resolvedEditorModule?.default ?? null;
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
}
return ( return (
<GridEditorRuntimeProvider status="ready"> <GridEditorRegistryProvider>
<Provider>{children}</Provider> <GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
</GridEditorRuntimeProvider> {isEditMode && Provider && GridEditor && (
<Provider>
<RegisteredGridEditors GridEditor={GridEditor} />
</Provider>
)}
</GridEditorRegistryProvider>
);
};
const RegisteredGridEditors = ({ GridEditor }: { GridEditor: LoadedGridEditorModule["default"] }) => {
const editors = useRegisteredGridEditors();
return Array.from(editors.values(), ({ host, disabled, ...props }) =>
disabled ? null : createPortal(<GridEditor {...props} />, host, props.sectionId),
); );
}; };
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react"; import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
import { useId, useRef, useState } from "react"; import { useCallback, useId, useRef, useState } from "react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import combineClasses from "clsx"; import combineClasses from "clsx";
@@ -34,7 +34,7 @@ interface FixedGridItemProps {
/** /**
* Static SSR geometry in view mode and the stable content/keyboard handle in * Static SSR geometry in view mode and the stable content/keyboard handle in
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit * edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
* handles pointer and touch input on the outer editor shell. * binds pointer and touch input to this same element without replacing it.
*/ */
export const FixedGridItem = ({ export const FixedGridItem = ({
item, item,
@@ -46,11 +46,16 @@ export const FixedGridItem = ({
const runtimeStatus = useGridEditorRuntimeStatus(); const runtimeStatus = useGridEditorRuntimeStatus();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const t = useI18n(); const t = useI18n();
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext(); const { section, columnCount, maxRowCount, placements, interactionDisabled, announce, entryElementStore } =
useSectionContext();
const { commitSectionGrid } = useGridLayoutActions(); const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false); const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const { isSelected, toggleSelectItem } = useBoardSelection(); const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id); const selected = item.type === "item" && isSelected(item.id);
const registerElement = useCallback(
(element: HTMLDivElement | null) => entryElementStore.register(item.id, element),
[entryElementStore, item.id],
);
const handleClickCapture = (event: React.MouseEvent) => { const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return; if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
@@ -60,15 +65,14 @@ export const FixedGridItem = ({
toggleSelectItem(item.id); toggleSelectItem(item.id);
}; };
const instructionsId = useId(); const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready"; const isEditorActive = isEditMode && runtimeStatus === "ready" && !interactionDisabled;
const placement = toPlacement(item); const placement = toPlacement(item);
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
const keyboardGridRef = useRef<{ const keyboardGridRef = useRef<{
source: readonly SectionGridPlacement[]; source: readonly SectionGridPlacement[];
current: readonly SectionGridPlacement[]; current: readonly SectionGridPlacement[];
}>({ }>({
source: renderedPlacements, source: placements,
current: renderedPlacements, current: placements,
}); });
const displayName = const displayName =
item.type === "item" item.type === "item"
@@ -111,14 +115,14 @@ export const FixedGridItem = ({
setKeyboardEditing(false); setKeyboardEditing(false);
return; return;
} }
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) { if (!arePlacementListsEqual(keyboardGridRef.current.source, placements)) {
keyboardGridRef.current = { keyboardGridRef.current = {
source: renderedPlacements, source: placements,
current: renderedPlacements, current: placements,
}; };
} }
const placements = keyboardGridRef.current.current; const currentPlacements = keyboardGridRef.current.current;
const current = placements.find((candidate) => candidate.id === item.id); const current = currentPlacements.find((candidate) => candidate.id === item.id);
if (!current) return; if (!current) return;
let transaction: GridTransaction<SectionGridPlacement>; let transaction: GridTransaction<SectionGridPlacement>;
@@ -130,7 +134,7 @@ export const FixedGridItem = ({
id: section.id, id: section.id,
columnCount, columnCount,
maxRowCount, maxRowCount,
placements, placements: currentPlacements,
}, },
], ],
}, },
@@ -138,8 +142,8 @@ export const FixedGridItem = ({
); );
} catch { } catch {
keyboardGridRef.current = { keyboardGridRef.current = {
source: renderedPlacements, source: currentPlacements,
current: renderedPlacements, current: currentPlacements,
}; };
setKeyboardEditing(false); setKeyboardEditing(false);
return; return;
@@ -188,13 +192,22 @@ export const FixedGridItem = ({
return ( return (
<Box <Box
ref={registerElement}
{...semantics} {...semantics}
onClickCapture={handleClickCapture} onClickCapture={handleClickCapture}
data-board-item-selected={selected ? "true" : undefined} data-board-item-selected={selected ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)} className={combineClasses(
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)} classes.staticItem,
data-grid-item-id={isEditorActive ? undefined : item.id} isEditorActive && "board-grid-entry",
data-grid-item-type={isEditorActive ? undefined : item.type} isEditorActive && classes.editorEntry,
)}
style={getLogicalItemStyle(placement)}
data-grid-item-id={item.id}
data-grid-item-type={item.type}
data-grid-x={placement.x}
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-editor-grid-entry={isEditorActive ? "true" : undefined} data-editor-grid-entry={isEditorActive ? "true" : undefined}
data-type={item.type} data-type={item.type}
data-kind={item.kind} data-kind={item.kind}
@@ -209,7 +222,7 @@ export const FixedGridItem = ({
onBlur={isEditorActive ? handleBlur : undefined} onBlur={isEditorActive ? handleBlur : undefined}
onKeyDown={isEditorActive ? handleKeyboard : undefined} onKeyDown={isEditorActive ? handleKeyboard : undefined}
> >
{children} <div className={combineClasses("board-grid-content-mount", classes.contentMount)}>{children}</div>
{isEditorActive && ( {isEditorActive && (
<span id={instructionsId} className={classes.liveRegion}> <span id={instructionsId} className={classes.liveRegion}>
{t("item.moveResize.keyboard.instructions")} {t("item.moveResize.keyboard.instructions")}
@@ -3,16 +3,24 @@
import type * as GridEditorModule from "./grid-editor"; import type * as GridEditorModule from "./grid-editor";
let editorModulePromise: Promise<typeof GridEditorModule> | undefined; let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
let editorModule: typeof GridEditorModule | undefined;
export const loadGridEditorAsync = () => { export const loadGridEditorAsync = () => {
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => { editorModulePromise ??= import("./grid-editor")
editorModulePromise = undefined; .then((loadedModule) => {
throw error; editorModule = loadedModule;
}); return loadedModule;
})
.catch((error: unknown) => {
editorModulePromise = undefined;
throw error;
});
return editorModulePromise; return editorModulePromise;
}; };
export const getLoadedGridEditorModule = () => editorModule;
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => { export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
let animationFrameId: number | undefined; let animationFrameId: number | undefined;
let idleCallbackId: number | undefined; let idleCallbackId: number | undefined;
@@ -0,0 +1,117 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useState, useSyncExternalStore } from "react";
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface GridEntryElementSnapshot {
revision: number;
elements: ReadonlyMap<string, HTMLElement>;
}
export interface GridEntryElementStore {
getSnapshot: () => GridEntryElementSnapshot;
subscribe: (listener: () => void) => () => void;
register: (id: string, element: HTMLElement | null) => void;
}
export const createGridEntryElementStore = (): GridEntryElementStore => {
const elements = new Map<string, HTMLElement>();
const listeners = new Set<() => void>();
let snapshot: GridEntryElementSnapshot = { revision: 0, elements };
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
register: (id, element) => {
const current = elements.get(id);
if (element ? current === element : current === undefined) return;
if (element) elements.set(id, element);
else elements.delete(id);
snapshot = { revision: snapshot.revision + 1, elements };
for (const listener of listeners) listener();
},
};
};
export interface GridEditorRenderProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
transactionPlacements: readonly SectionGridPlacement[];
className: string;
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
items: SectionItem[];
innerSections: ContainerSectionItem[];
entryElementStore: GridEntryElementStore;
}
export interface RegisteredGridEditor extends GridEditorRenderProps {
host: HTMLElement;
disabled: boolean;
}
interface GridEditorRegistrySnapshot {
revision: number;
editors: ReadonlyMap<string, RegisteredGridEditor>;
}
interface GridEditorRegistry {
getSnapshot: () => GridEditorRegistrySnapshot;
subscribe: (listener: () => void) => () => void;
register: (editor: RegisteredGridEditor) => () => void;
}
const createGridEditorRegistry = (): GridEditorRegistry => {
const editors = new Map<string, RegisteredGridEditor>();
const listeners = new Set<() => void>();
let snapshot: GridEditorRegistrySnapshot = { revision: 0, editors };
const publish = () => {
snapshot = { revision: snapshot.revision + 1, editors };
for (const listener of listeners) listener();
};
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
register: (editor) => {
editors.set(editor.sectionId, editor);
publish();
return () => {
if (editors.get(editor.sectionId) !== editor) return;
editors.delete(editor.sectionId);
publish();
};
},
};
};
const GridEditorRegistryContext = createContext<GridEditorRegistry | null>(null);
export const GridEditorRegistryProvider = ({ children }: PropsWithChildren) => {
const [registry] = useState(createGridEditorRegistry);
return <GridEditorRegistryContext.Provider value={registry}>{children}</GridEditorRegistryContext.Provider>;
};
export const useGridEditorRegistry = () => {
const registry = useContext(GridEditorRegistryContext);
if (!registry) throw new Error("GridEditorRegistryProvider is required");
return registry;
};
export const useRegisteredGridEditors = () => {
const registry = useGridEditorRegistry();
return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot).editors;
};
@@ -3,6 +3,7 @@
inset: 0 auto auto 0; inset: 0 auto auto 0;
overflow: visible; overflow: visible;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
pointer-events: none;
} }
.board-grid-entry { .board-grid-entry {
@@ -22,6 +23,13 @@
will-change: translate; 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;
}
.board-grid-entry[data-dnd-active="true"], .board-grid-entry[data-dnd-active="true"],
.board-grid-entry[data-dnd-drag-source="true"] { .board-grid-entry[data-dnd-drag-source="true"] {
z-index: 1000; z-index: 1000;
@@ -101,24 +109,12 @@
zoom: var(--board-grid-overlay-content-zoom, 1); zoom: var(--board-grid-overlay-content-zoom, 1);
} }
body[data-board-grid-interacting] .board-grid-entry, 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"],
body[data-board-grid-interacting] .board-grid-placeholder { body[data-board-grid-interacting] .board-grid-placeholder {
transition: none; transition: none;
} }
.board-grid-content-mount,
.board-grid-portal-content,
.board-grid-portal-content > [data-editor-grid-entry] {
width: 100%;
height: 100%;
overflow: visible;
}
.board-grid-content-mount {
position: absolute;
inset: 0;
}
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount { body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
overflow: clip; overflow: clip;
} }
@@ -13,6 +13,7 @@ import {
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
@@ -37,8 +38,7 @@ import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { import {
beginGridTransaction, beginGridTransaction,
cancelGridTransaction, cancelGridTransaction,
@@ -57,27 +57,18 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
import type { DndGridEntryProps } from "./grid-entry-memo"; import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo"; import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth"; import { getGridDepth } from "./grid-depth";
import type { GridEditorRenderProps } from "./grid-editor-registry";
import { createGridInteractionStore } from "./grid-interaction-store"; import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host"; import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer"; import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer"; import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline"; import { createGridResizeOutlineController } from "./grid-resize-outline";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions";
const GRID_ENTRY_TYPE = "homarr-board-grid-entry"; const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
const GRID_TARGET_TYPE = "homarr-board-grid-target"; const GRID_TARGET_TYPE = "homarr-board-grid-target";
interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
interface GridEntryData { interface GridEntryData {
kind: typeof GRID_ENTRY_TYPE; kind: typeof GRID_ENTRY_TYPE;
sectionId: string; sectionId: string;
@@ -727,14 +718,22 @@ export default function GridEditor({
rowCount, rowCount,
maxRowCount, maxRowCount,
placements, placements,
transactionPlacements,
className, className,
}: GridEditorProps) { section,
items,
innerSections,
entryElementStore,
}: GridEditorRenderProps) {
const t = useI18n(); const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions(); const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore(); const interactionStore = useBoardGridEditorInteractionStore();
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [entryElements] = useState(() => new Map<string, HTMLElement>()); const entryElements = useSyncExternalStore(
entryElementStore.subscribe,
entryElementStore.getSnapshot,
entryElementStore.getSnapshot,
).elements;
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null; const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null; const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId); const depth = getDepth(sectionId);
@@ -769,14 +768,6 @@ export default function GridEditor({
}, },
[droppableRef], [droppableRef],
); );
const registerEntryElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) entryElements.set(id, element);
else entryElements.delete(id);
},
[entryElements],
);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = gridRef.current; const element = gridRef.current;
if (!element) return; if (!element) return;
@@ -786,11 +777,11 @@ export default function GridEditor({
maxRowCount, maxRowCount,
parentGridId, parentGridId,
ownerPlacementId, ownerPlacementId,
placements, placements: transactionPlacements,
depth, depth,
element, element,
}); });
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]); }, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
return ( return (
<div <div
@@ -815,7 +806,7 @@ export default function GridEditor({
label={labelById.get(placement.id) ?? placement.id} label={labelById.get(placement.id) ?? placement.id}
columnCount={columnCount} columnCount={columnCount}
maxRowCount={maxRowCount} maxRowCount={maxRowCount}
registerElement={registerEntryElement} element={entryElements.get(placement.id) ?? null}
/> />
); );
})} })}
@@ -844,24 +835,18 @@ const DndGridEntryComponent = ({
label, label,
columnCount, columnCount,
maxRowCount, maxRowCount,
registerElement, element,
}: DndGridEntryProps) => { }: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions(); const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { isSelected } = useBoardSelection();
const board = useRequiredBoard();
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement); const placementRef = useRef(placement);
placementRef.current = placement; placementRef.current = placement;
const resizeOutlineController = useMemo( const resizeOutlineController = useMemo(
() => () =>
createGridResizeOutlineController( createGridResizeOutlineController(
() => shellRef.current, () => element,
() => placementRef.current, () => placementRef.current,
), ),
[], [element],
); );
const sourceData = useMemo<GridEntryData>( const sourceData = useMemo<GridEntryData>(
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }), () => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
@@ -882,67 +867,39 @@ const DndGridEntryComponent = ({
const modifiers = useMemo( const modifiers = useMemo(
() => [ () => [
RestrictToElement.configure({ RestrictToElement.configure({
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null, element: () => element?.closest('[data-testid="board-canvas"]') ?? null,
}), }),
], ],
[], [element],
); );
const { const { isDragSource, isDropping } = useDraggable<GridEntryData>({
ref: draggableRef,
handleRef,
isDragSource,
isDropping,
} = useDraggable<GridEntryData>({
id: placement.id, id: placement.id,
type: GRID_ENTRY_TYPE, type: GRID_ENTRY_TYPE,
data: sourceData, data: sourceData,
element,
handle: element,
disabled: element === null,
sensors, sensors,
modifiers, modifiers,
}); });
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
handleRef(element);
},
[draggableRef, handleRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]); useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
useLayoutEffect(() => { useLayoutEffect(() => {
const mount = mountRef.current; if (!element) return;
if (!mount) return; if (isDragSource) element.dataset.dndDragSource = "true";
const container = acquireContainer(placement.id); else delete element.dataset.dndDragSource;
mount.replaceChildren(container); if (isDropping) element.dataset.dndDropping = "true";
else delete element.dataset.dndDropping;
return () => { return () => {
if (container.parentElement === mount) container.remove(); delete element.dataset.dndDragSource;
delete element.dataset.dndDropping;
}; };
}, [acquireContainer, placement.id, structureRevision]); }, [element, isDragSource, isDropping]);
return ( if (!element) return null;
<div
ref={setShellRef} return createPortal(
className="board-grid-entry" <>
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
<GridResizeHandles <GridResizeHandles
sectionId={sectionId} sectionId={sectionId}
placement={placement} placement={placement}
@@ -955,7 +912,9 @@ const DndGridEntryComponent = ({
cancelResize={cancelResize} cancelResize={cancelResize}
scheduleResizeOutline={resizeOutlineController.schedule} scheduleResizeOutline={resizeOutlineController.schedule}
/> />
</div> </>,
element,
`board-grid-resize-handles:${placement.id}`,
); );
}; };
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
label: string; label: string;
columnCount: number; columnCount: number;
maxRowCount: number | null; maxRowCount: number | null;
registerElement: (id: string, element: HTMLElement | null) => void; element: HTMLElement | null;
} }
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) => export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
previous.label === next.label && previous.label === next.label &&
previous.columnCount === next.columnCount && previous.columnCount === next.columnCount &&
previous.maxRowCount === next.maxRowCount && previous.maxRowCount === next.maxRowCount &&
previous.registerElement === next.registerElement && previous.element === next.element &&
previous.placement.id === next.placement.id && previous.placement.id === next.placement.id &&
previous.placement.type === next.placement.type && previous.placement.type === next.placement.type &&
previous.placement.x === next.placement.x && previous.placement.x === next.placement.x &&
@@ -2,38 +2,23 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import classes from "./section-grid.module.css"; import classes from "./section-grid.module.css";
interface GridPortalHostContextValue { interface GridPortalHostContextValue {
announce: (message: string) => void; announce: (message: string) => void;
integrations: RouterOutputs["integration"]["all"] | undefined; integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap<string, HTMLElement>; getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
acquireContainer: (id: string) => HTMLElement;
structureRevision: string;
} }
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null); const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
/** /** Shared board-grid services for editor announcements and integration data. */
* One portal registry owns every edit-mode item on the board. Its content
* containers are stable even when drag-and-drop replaces or transfers the
* outer positioning shell, so moving between grids does not remount widget
* content.
*/
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { data: session } = useSession(); const { data: session } = useSession();
@@ -41,51 +26,27 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const { data: integrations } = clientApi.integration.all.useQuery(undefined, { const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode, enabled: Boolean(session) && isEditMode,
}); });
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
const [announcement, setAnnouncement] = useState({ id: 0, message: "" }); const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
const entryRuntimesRef = useRef(new Map<string, unknown>());
const announce = useCallback((message: string) => { const announce = useCallback((message: string) => {
setAnnouncement((previous) => ({ id: previous.id + 1, message })); setAnnouncement((previous) => ({ id: previous.id + 1, message }));
}, []); }, []);
const liveEntryIds = useMemo( const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]), const existing = entryRuntimesRef.current.get(entryId);
[board.items, board.sections], if (existing !== undefined) return existing as TRuntime;
); const runtime = createRuntime();
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]); entryRuntimesRef.current.set(entryId, runtime);
return runtime;
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
if (existing) return existing;
const container = document.createElement("div");
container.className = "board-grid-portal-content";
container.dataset.boardGridPortal = id;
containersRef.current.set(id, container);
setContainers(new Map(containersRef.current));
return container;
}, []); }, []);
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
useEffect(() => { useEffect(() => {
// A cross-grid move can briefly have no shell, but the entry still exists for (const entryId of entryRuntimesRef.current.keys()) {
// on the board. Only remove containers whose board entity was deleted. if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
let didPrune = false;
for (const id of containersRef.current.keys()) {
if (liveEntryIds.has(id)) continue;
containersRef.current.delete(id);
didPrune = true;
} }
if (didPrune) setContainers(new Map(containersRef.current)); }, [liveItemIds]);
}, [liveEntryIds]);
const value = useMemo<GridPortalHostContextValue>( const value = useMemo<GridPortalHostContextValue>(
() => ({ () => ({ announce, integrations, getEntryRuntime }),
announce, [announce, getEntryRuntime, integrations],
integrations,
containers,
acquireContainer,
structureRevision,
}),
[acquireContainer, announce, containers, integrations, structureRevision],
); );
return ( return (
@@ -98,158 +59,8 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
); );
}; };
/**
* Renders stable portal content at this component's logical React position.
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
*/
export const BoardGridPortalRenderer = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { announce, containers, integrations } = useBoardGridPortalHost();
return (
<>
{Array.from(containers, ([id, container]) => {
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
return ownerSectionId
? createPortal(
<GridPortalContent
itemId={id}
ownerSectionId={ownerSectionId}
integrations={integrations}
announce={announce}
/>,
container,
id,
)
: null;
})}
</>
);
};
export const useBoardGridPortalHost = () => { export const useBoardGridPortalHost = () => {
const context = useContext(GridPortalHostContext); const context = useContext(GridPortalHostContext);
if (!context) throw new Error("BoardGridPortalHost is required"); if (!context) throw new Error("BoardGridPortalHost is required");
return context; return context;
}; };
interface GridPortalContentProps {
itemId: string;
ownerSectionId: string;
integrations: RouterOutputs["integration"]["all"] | undefined;
announce: (message: string) => void;
}
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
if (!section || !currentLayout) return null;
const columnCount = getSectionColumnCount(section, currentLayout);
if (columnCount < 1) return null;
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
if (!entry) return null;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
...renderedItems.map((item) => item.yOffset + item.height),
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
return (
<SectionProvider
value={{
section,
items: renderedItems,
innerSections: renderedInnerSections,
integrations,
columnCount,
maxRowCount,
announce,
}}
>
<SectionContentItem item={entry} integrations={integrations} />
</SectionProvider>
);
};
const getRenderedSection = (
board: ReturnType<typeof useRequiredBoard>,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
sectionId: string,
visited = new Set<string>(),
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind !== "container") return section;
if (visited.has(section.id)) return null;
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
if (!layout) return null;
const parentSection = getRenderedSection(
board,
currentLayout,
layout.parentSectionId,
new Set(visited).add(section.id),
);
if (!parentSection) return null;
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
if (parentColumnCount < 1) return null;
const normalized = normalizePortalEntry(layout, parentColumnCount);
return {
...section,
...normalized,
type: "section",
};
};
const getSectionColumnCount = (
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
) =>
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const normalizePortalEntry = <
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
>(
entry: TEntry,
columnCount: number,
) => {
const placement = normalizeGridPlacement(
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
columnCount,
);
return {
...entry,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
};
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
const item = board.items.find((candidate) => candidate.id === entryId);
if (item) {
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
}
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
if (!section || section.kind !== "container") return null;
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
};
@@ -14,19 +14,23 @@
inset: 0 auto auto 0; inset: 0 auto auto 0;
} }
.editorPortalHost {
display: contents;
}
.staticItem { .staticItem {
overflow: visible; overflow: visible;
transition: }
left 220ms ease,
top 220ms ease, .contentMount {
width 220ms ease, position: absolute;
height 220ms ease; inset: 0;
width: 100%;
height: 100%;
overflow: visible;
} }
.editorEntry { .editorEntry {
position: relative;
width: 100%;
height: 100%;
overflow: visible; overflow: visible;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
@@ -43,19 +47,6 @@
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.editorLoading {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
}
.editorLoadingItem {
background-color: var(--mantine-color-default);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.liveRegion { .liveRegion {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -67,9 +58,3 @@
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
@media (prefers-reduced-motion: reduce) {
.staticItem {
transition: none;
}
}
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import dynamic from "next/dynamic"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box, LoadingOverlay } from "@mantine/core"; import { Box } from "@mantine/core";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types"; import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { import {
@@ -15,56 +14,21 @@ import {
getEditableCanvasAttributes, getEditableCanvasAttributes,
getGridRowCountForVisualHeight, getGridRowCountForVisualHeight,
getLayoutRowCount, getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize, getLogicalTrackSize,
getReadonlyCanvasAttributes, getReadonlyCanvasAttributes,
normalizeGridPlacement, normalizeGridPlacement,
} from "~/components/board/layout"; } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions"; import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { SectionContent } from "../content"; import { SectionContent } from "../content";
import { useCollapsedSectionIds } from "../section-collapse"; import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
import { SectionProvider, useSectionContext } from "../section-context"; import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items"; import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host"; import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css"; import classes from "./section-grid.module.css";
const GridEditor = dynamic(loadGridEditorAsync, {
loading: GridEditorLoading,
ssr: false,
});
function GridEditorLoading() {
const t = useI18n();
const { items, innerSections } = useSectionContext();
return (
<Box
component="output"
className={classes.editorLoading}
data-testid="board-grid-editor-loading"
aria-label={t("common.action.loading")}
>
{[...items, ...innerSections].map((item) => (
<Box
key={item.id}
className={classes.editorLoadingItem}
style={getLogicalItemStyle({
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
})}
aria-hidden="true"
/>
))}
<LoadingOverlay visible />
</Box>
);
}
interface SectionGridProps { interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem; section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number; columnCount: number;
@@ -85,21 +49,19 @@ export const SectionGrid = ({
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale(); const canvasScale = useBoardCanvasScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus(); const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
const [entryElementStore] = useState(createGridEntryElementStore);
const board = useRequiredBoard(); const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(section.id); const { items, innerSections } = useSectionItems(section.id);
const { announce, integrations } = useBoardGridPortalHost(); const { announce, integrations } = useBoardGridPortalHost();
const collapsedSectionIds = useCollapsedSectionIds(); const collapsedSectionIds = useCollapsedSectionIds();
const minimumBySectionId = useMemo( const expandSectionsForEditing = useExpandSectionsForEditing();
() => const minimumBySectionId = useMemo(() => {
new Map( const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
innerSections.map((innerSection) => [ return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
innerSection.id, }, [board, currentLayoutId, innerSections]);
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
]),
),
[board, currentLayoutId, innerSections],
);
const placements = useMemo( const placements = useMemo(
() => () =>
@@ -121,41 +83,112 @@ export const SectionGrid = ({
}), }),
[columnCount, innerSections, items, minimumBySectionId], [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(() => { const displayPlacements = useMemo(() => {
const directCollapsedIds = new Set(
placements
.filter(
(placement) =>
placement.type === "section" &&
collapsedSectionIds.has(placement.id) &&
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
)
.map((placement) => placement.id),
);
if (directCollapsedIds.size === 0) return placements; if (directCollapsedIds.size === 0) return placements;
return getCollapsedDisplayLayout(placements, { return getCollapsedDisplayLayout(placements, {
columnCount, columnCount,
collapsedItemIds: directCollapsedIds, collapsedItemIds: directCollapsedIds,
}); });
}, [collapsedSectionIds, columnCount, innerSections, placements]); }, [columnCount, directCollapsedIds, placements]);
const placementById = useMemo( const placementById = useMemo(
() => new Map(displayPlacements.map((placement) => [placement.id, placement])), () => new Map(displayPlacements.map((placement) => [placement.id, placement])),
[displayPlacements], [displayPlacements],
); );
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id))); const displayedItems = useMemo(
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id))); () => 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 minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null; const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount); const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount); const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode // A collapsed container's compact coordinates are display-only. Its own
? getEditableCanvasAttributes({ label, columnCount, rowCount }) // nested grid stays inactive until an explicit edit interaction expands it.
: getReadonlyCanvasAttributes({ label }); 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,
placements: displayPlacements,
transactionPlacements: placements,
className: editorClassName,
entryElementStore,
});
}, [
columnCount,
displayPlacements,
displayedInnerSections,
displayedItems,
editorClassName,
editorRegistry,
entryElementStore,
isInteractionDisabled,
maxRowCount,
placements,
rowCount,
section,
]);
return ( return (
<SectionProvider <SectionProvider
@@ -166,47 +199,48 @@ export const SectionGrid = ({
integrations, integrations,
columnCount, columnCount,
maxRowCount, maxRowCount,
placements: displayPlacements,
interactionDisabled: isInteractionDisabled,
announce, announce,
entryElementStore,
}} }}
> >
<Box <Box
{...canvasAttributes} {...canvasAttributes}
className={combineClasses(classes.viewport, className)} className={combineClasses(classes.viewport, className)}
style={{ style={
width: logicalWidth, {
height: `var(--board-grid-drag-height, ${logicalHeight}px)`, width: logicalWidth,
}} height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-section-id={section.id} data-section-id={section.id}
data-section-kind={section.kind} data-section-kind={section.kind}
data-rail-placement={railPlacement} data-rail-placement={railPlacement}
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
> >
{isEditMode && editorRuntimeStatus === "ready" ? ( <Box
<GridEditor className={classes.staticGrid}
sectionId={section.id} style={{ width: logicalWidth, height: logicalHeight }}
columnCount={columnCount} data-grid-section-id={section.id}
rowCount={rowCount} data-kind={section.kind}
maxRowCount={maxRowCount} data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
placements={displayPlacements} >
className={combineClasses("board-grid-editor", classes.editorGrid)} <SectionContent />
/> </Box>
) : isEditMode && editorRuntimeStatus === "loading" ? ( <div ref={editorHostRef} className={classes.editorPortalHost} />
<GridEditorLoading />
) : (
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
</Box>
)}
</Box> </Box>
</SectionProvider> </SectionProvider>
); );
}; };
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 useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const [visualHeight, setVisualHeight] = useState(0); const [visualHeight, setVisualHeight] = useState(0);
@@ -225,37 +259,70 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0; return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
}; };
const getContainerMinimumSize = ( const containerMinimumSizeCache = new WeakMap<
board: ReturnType<typeof useRequiredBoard>, ReturnType<typeof useRequiredBoard>,
layoutId: string, Map<string, ReadonlyMap<string, { width: number; height: number }>>
sectionId: string, >();
visited = new Set<string>(),
): { width: number; height: number } => {
if (visited.has(sectionId)) return { width: 1, height: 1 };
const nextVisited = new Set(visited).add(sectionId);
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId ? [layout] : [];
});
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
if (candidate.kind !== "container") return [];
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
if (!layout || layout.parentSectionId !== sectionId) return [];
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
return [
{
...layout,
width: Math.max(layout.width, minimum.width),
height: Math.max(layout.height, minimum.height),
},
];
});
const children = [...directItems, ...directSections];
return { const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, layoutId: string) => {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)), const cachedByLayout = containerMinimumSizeCache.get(board);
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)), const cached = cachedByLayout?.get(layoutId);
if (cached) return cached;
const directItemsBySectionId = new Map<string, PlacementBounds[]>();
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<string, { id: string; placement: PlacementBounds }[]>();
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<string, { width: number; height: number }>();
const visiting = new Set<string>();
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 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: 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 { interface PlacementBounds {
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
commitSectionGrid: vi.fn(), commitSectionGrid: vi.fn(),
isSelected: vi.fn(() => false), isSelected: vi.fn(() => false),
toggleSelectItem: vi.fn(), toggleSelectItem: vi.fn(),
registerElement: vi.fn(),
editMode: true, editMode: true,
maxRowCount: null as number | null, maxRowCount: null as number | null,
items: [ items: [
@@ -79,7 +80,17 @@ vi.mock("../../section-context", () => ({
innerSections: [], innerSections: [],
columnCount: 3, columnCount: 3,
maxRowCount: mocks.maxRowCount, maxRowCount: mocks.maxRowCount,
placements: mocks.items.map((item) => ({
id: item.id,
type: item.type,
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
})),
interactionDisabled: false,
announce: mocks.announce, announce: mocks.announce,
entryElementStore: { register: mocks.registerElement },
}), }),
})); }));
@@ -98,6 +109,7 @@ describe("fixed grid item behavior", () => {
beforeEach(() => { beforeEach(() => {
mocks.announce.mockReset(); mocks.announce.mockReset();
mocks.commitSectionGrid.mockReset(); mocks.commitSectionGrid.mockReset();
mocks.registerElement.mockReset();
mocks.editMode = true; mocks.editMode = true;
mocks.maxRowCount = null; mocks.maxRowCount = null;
getWeatherMock().advancedOptions.title = null; getWeatherMock().advancedOptions.title = null;
@@ -22,7 +22,6 @@ describe("grid entry memoization", () => {
{ {
"data-grid-id": props.placement.id, "data-grid-id": props.placement.id,
"data-x": props.placement.x, "data-x": props.placement.x,
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
}, },
createElement(WidgetProbe), createElement(WidgetProbe),
); );
@@ -79,21 +78,17 @@ describe("grid entry memoization", () => {
}); });
test("keeps 100 entry and widget renders frozen across interaction store previews", () => { test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
const elements = new Map<string, HTMLElement>();
const interactionStore = createGridInteractionStore<GridInteraction>(); const interactionStore = createGridInteractionStore<GridInteraction>();
const gridRef = { current: null as HTMLDivElement | null }; const gridRef = { current: null as HTMLDivElement | null };
const setGridRef = (element: HTMLDivElement | null) => { const setGridRef = (element: HTMLDivElement | null) => {
gridRef.current = element; gridRef.current = element;
}; };
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
};
const entries = Array.from({ length: 100 }, (_, index) => ({ const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(), ...createProps(),
registerElement, element: document.createElement("span"),
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) }, placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
})); }));
const elements = new Map(entries.map((entry) => [entry.placement.id, entry.element]));
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => { const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
act(() => act(() =>
root.render( root.render(
@@ -160,7 +155,7 @@ describe("grid entry memoization", () => {
test.each([ test.each([
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })], ["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })], ["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })], ["bound element", (props: DndGridEntryProps) => ({ ...props, element: document.createElement("span") })],
[ [
"minimum size", "minimum size",
(props: DndGridEntryProps) => ({ (props: DndGridEntryProps) => ({
@@ -198,5 +193,5 @@ const createProps = (): DndGridEntryProps => ({
label: "Weather", label: "Weather",
columnCount: 12, columnCount: 12,
maxRowCount: null, maxRowCount: null,
registerElement: () => undefined, element: null,
}); });
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
const gridDirectory = resolve(import.meta.dirname, ".."); const gridDirectory = resolve(import.meta.dirname, "..");
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../"); const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx"); const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.tsx");
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts"); const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => { describe("board grid import boundary", () => {
@@ -33,15 +34,16 @@ describe("board grid import boundary", () => {
]); ]);
}); });
test("loads the editor through next/dynamic only in the edit branch", () => { test("loads the editor only in edit mode without replacing static board content", () => {
const source = readFileSync(sectionGridPath, "utf8"); const sectionSource = readFileSync(sectionGridPath, "utf8");
const boundarySource = readFileSync(editorBoundaryPath, "utf8");
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/); expect(sectionSource).not.toMatch(/import dynamic from ["']next\/dynamic["']/);
expect(source).toMatch( expect(sectionSource).toMatch(/<SectionContent\s*\/>/);
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/, expect(sectionSource).toMatch(/<div ref={editorHostRef} className={classes\.editorPortalHost}\s*\/>/);
); expect(boundarySource).toMatch(/loadGridEditorAsync\(\)/);
expect(source.match(/<GridEditor\b/g)).toHaveLength(1); expect(boundarySource).toMatch(/isEditMode\s*&&\s*Provider\s*&&\s*GridEditor/);
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/); expect(boundarySource).toMatch(/createPortal\(<GridEditor \{\.\.\.props} \/>, host, props\.sectionId\)/);
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]); expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
expect( expect(
getRuntimeImports(editorLoaderPath) getRuntimeImports(editorLoaderPath)
@@ -1,104 +0,0 @@
import { act, createElement, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
const mocks = vi.hoisted(() => ({
board: {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
},
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
integration: {
all: {
useQuery: () => ({ data: undefined }),
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { id: "user" } } }),
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "desktop",
useRequiredBoard: () => mocks.board,
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [true],
}));
describe("board grid portal host", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
};
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
const acquired: HTMLElement[] = [];
renderHost(root, true, 0, acquired);
expect(getContainerCount(container)).toBe("1");
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
sections: [],
};
renderHost(root, true, 1, acquired);
expect(getContainerCount(container)).toBe("1");
expect(new Set(acquired).size).toBe(1);
mocks.board = { items: [], sections: [] };
renderHost(root, false, 2, acquired);
expect(getContainerCount(container)).toBe("0");
});
});
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
act(() => {
root.render(
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
);
});
};
const PortalRegistryProbe = ({
acquire,
revision,
acquired,
}: {
acquire: boolean;
revision: number;
acquired: HTMLElement[];
}) => {
const portalHost = useBoardGridPortalHost();
useLayoutEffect(() => {
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
}, [acquire, acquired, portalHost, revision]);
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
};
const getContainerCount = (container: HTMLElement) =>
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;
@@ -12,13 +12,16 @@ import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from
interface SectionCollapseContextValue { interface SectionCollapseContextValue {
collapsedSectionIds: ReadonlySet<string>; collapsedSectionIds: ReadonlySet<string>;
visuallyCollapsedSectionIds: ReadonlySet<string>;
setCollapsed: (sectionId: string, collapsed: boolean) => void; setCollapsed: (sectionId: string, collapsed: boolean) => void;
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
} }
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null); const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => { export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { status } = useSession(); const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation(); const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() => const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
@@ -27,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
), ),
); );
const collapsedByIdRef = useRef(collapsedById); const collapsedByIdRef = useRef(collapsedById);
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
collapsedByIdRef.current = collapsedById; collapsedByIdRef.current = collapsedById;
useEffect(() => {
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
}, [isEditMode]);
useEffect(() => { useEffect(() => {
setCollapsedById((previous) => { setCollapsedById((previous) => {
const next = { ...previous }; const next = { ...previous };
@@ -57,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false; const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed }; collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed })); setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
setTemporarilyExpandedIds((current) => {
if (!current.has(sectionId)) return current;
const next = new Set(current);
next.delete(sectionId);
return next;
});
if (status === "authenticated") { if (status === "authenticated") {
mutate( mutate(
@@ -76,47 +90,70 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
[mutate, status], [mutate, status],
); );
const value = useMemo<SectionCollapseContextValue>( const expandForEditing = useCallback(
() => ({ (sectionIds: ReadonlySet<string>) => {
collapsedSectionIds: new Set( if (!isEditMode || sectionIds.size === 0) return;
Object.entries(collapsedById) setTemporarilyExpandedIds((current) => {
.filter(([, collapsed]) => collapsed) const next = new Set(current);
.map(([sectionId]) => sectionId), for (const sectionId of sectionIds) next.add(sectionId);
return next.size === current.size ? current : next;
});
},
[isEditMode],
);
const value = useMemo<SectionCollapseContextValue>(() => {
const collapsedSectionIds = new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
);
return {
collapsedSectionIds,
visuallyCollapsedSectionIds: new Set(
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
), ),
setCollapsed, setCollapsed,
}), expandForEditing,
[collapsedById, setCollapsed], };
); }, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>; return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
}; };
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => { export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
const context = useContext(SectionCollapseContext); const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) { if (!context) {
throw new Error("BoardSectionCollapseProvider is required"); throw new Error("BoardSectionCollapseProvider is required");
} }
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId); const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
return { return {
isCollapsed, isCollapsed,
isVisuallyCollapsed: isCollapsed && !isEditMode, isVisuallyCollapsed,
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed), setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
toggle: () => context.setCollapsed(sectionId, !isCollapsed), toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
}; };
}; };
export const useCollapsedSectionIds = () => { export const useCollapsedSectionIds = () => {
const context = useContext(SectionCollapseContext); const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) { if (!context) {
throw new Error("BoardSectionCollapseProvider is required"); throw new Error("BoardSectionCollapseProvider is required");
} }
return isEditMode ? EMPTY_SET : context.collapsedSectionIds; return context.visuallyCollapsedSectionIds;
}; };
const EMPTY_SET = new Set<string>(); export const useExpandSectionsForEditing = () => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.expandForEditing;
};
@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types"; import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { GridEntryElementStore } from "./grid/grid-editor-registry";
import type { SectionGridPlacement } from "./grid/use-grid-layout-actions";
interface SectionContextProps { interface SectionContextProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem; section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
@@ -11,7 +13,10 @@ interface SectionContextProps {
integrations: RouterOutputs["integration"]["all"] | undefined; integrations: RouterOutputs["integration"]["all"] | undefined;
columnCount: number; columnCount: number;
maxRowCount: number | null; maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
interactionDisabled: boolean;
announce: (message: string) => void; announce: (message: string) => void;
entryElementStore: GridEntryElementStore;
} }
const SectionContext = createContext<SectionContextProps | null>(null); const SectionContext = createContext<SectionContextProps | null>(null);
@@ -7,47 +7,55 @@ import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_t
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => { export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const index = useMemo(() => getSectionItemsIndex(board, currentLayoutId), [board, currentLayoutId]);
const innerSections = useMemo(
() =>
board.sections
.filter((innerSection) => innerSection.kind === "container")
.map(({ layouts, ...innerSection }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...innerSection,
type: "section" as const,
};
})
.filter((item) => item !== null)
.filter((innerSection) => innerSection.parentSectionId === sectionId),
[board.sections, currentLayoutId, sectionId],
);
const items = useMemo(
() =>
board.items
.map(({ layouts, ...item }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...item,
type: "item" as const,
};
})
.filter((item) => item !== null)
.filter((item) => item.sectionId === sectionId),
[board.items, currentLayoutId, sectionId],
);
return { return {
innerSections, innerSections: index.innerSectionsByParentId.get(sectionId) ?? EMPTY_INNER_SECTIONS,
items, items: index.itemsBySectionId.get(sectionId) ?? EMPTY_ITEMS,
}; };
}; };
interface SectionItemsIndex {
innerSectionsByParentId: ReadonlyMap<string, ContainerSectionItem[]>;
itemsBySectionId: ReadonlyMap<string, SectionItem[]>;
}
const sectionItemsIndexCache = new WeakMap<
ReturnType<typeof useRequiredBoard>,
Map<string, SectionItemsIndex>
>();
const EMPTY_INNER_SECTIONS: ContainerSectionItem[] = [];
const EMPTY_ITEMS: SectionItem[] = [];
const getSectionItemsIndex = (board: ReturnType<typeof useRequiredBoard>, layoutId: string): SectionItemsIndex => {
const cachedByLayout = sectionItemsIndexCache.get(board);
const cached = cachedByLayout?.get(layoutId);
if (cached) return cached;
const innerSectionsByParentId = new Map<string, ContainerSectionItem[]>();
for (const section of board.sections) {
if (section.kind !== "container") continue;
const { layouts, ...definition } = section;
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = innerSectionsByParentId.get(layout.parentSectionId) ?? [];
entries.push({ ...layout, ...definition, type: "section" });
innerSectionsByParentId.set(layout.parentSectionId, entries);
}
const itemsBySectionId = new Map<string, SectionItem[]>();
for (const item of board.items) {
const { layouts, ...definition } = item;
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = itemsBySectionId.get(layout.sectionId) ?? [];
entries.push({ ...layout, ...definition, type: "item" });
itemsBySectionId.set(layout.sectionId, entries);
}
const index = { innerSectionsByParentId, itemsBySectionId };
const nextByLayout = cachedByLayout ?? new Map();
nextByLayout.set(layoutId, index);
sectionItemsIndexCache.set(board, nextByLayout);
return index;
};