Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b6f08a36 | ||
|
|
5cd627d66c |
No files matched your search
@@ -20,7 +20,7 @@ import {
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
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 { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
@@ -80,7 +80,6 @@ export const ClientBoard = () => {
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
|
||||
@@ -25,12 +25,14 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize } from "~/components/board/layout";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
@@ -103,9 +105,11 @@ const WidgetDefinitionLoadError = ({
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
|
||||
const { getEntryRuntime } = useBoardGridPortalHost();
|
||||
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
|
||||
const width = measuredWidth || getLogicalTrackSize(item.width);
|
||||
const height = measuredHeight || getLogicalTrackSize(item.height);
|
||||
|
||||
return (
|
||||
<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 {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
|
||||
@@ -62,7 +62,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.collapsible && !isEditMode && (
|
||||
{options.collapsible && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
@@ -71,7 +71,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
@@ -101,7 +101,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
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";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
type LoadedGridEditorModule = Awaited<ReturnType<typeof loadGridEditorAsync>>;
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
||||
const [EditorModule, setEditorModule] = useState<LoadedGridEditorModule | null>(
|
||||
getLoadedGridEditorModule() ?? null,
|
||||
);
|
||||
const [hasLoadError, setHasLoadError] = useState(false);
|
||||
const resolvedEditorModule = EditorModule ?? getLoadedGridEditorModule() ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || Provider) return;
|
||||
if (!isEditMode || resolvedEditorModule) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHasLoadError(false);
|
||||
void loadGridEditorAsync()
|
||||
.then((module) => {
|
||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
||||
if (!cancelled) setEditorModule(module);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasLoadError(true);
|
||||
@@ -35,23 +41,34 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [Provider, isEditMode]);
|
||||
}, [isEditMode, resolvedEditorModule]);
|
||||
|
||||
if (!isEditMode) {
|
||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (hasLoadError) {
|
||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (!Provider) {
|
||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
const runtimeStatus = !isEditMode
|
||||
? "idle"
|
||||
: hasLoadError
|
||||
? "error"
|
||||
: resolvedEditorModule
|
||||
? "ready"
|
||||
: "loading";
|
||||
const Provider: BoardGridEditorProviderComponent | null = resolvedEditorModule?.BoardGridEditorProvider ?? null;
|
||||
const GridEditor = resolvedEditorModule?.default ?? null;
|
||||
|
||||
return (
|
||||
<GridEditorRuntimeProvider status="ready">
|
||||
<Provider>{children}</Provider>
|
||||
</GridEditorRuntimeProvider>
|
||||
<GridEditorRegistryProvider>
|
||||
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</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";
|
||||
|
||||
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 combineClasses from "clsx";
|
||||
|
||||
@@ -34,7 +34,7 @@ interface FixedGridItemProps {
|
||||
/**
|
||||
* 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
|
||||
* 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 = ({
|
||||
item,
|
||||
@@ -46,11 +46,16 @@ export const FixedGridItem = ({
|
||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { section, columnCount, maxRowCount, placements, interactionDisabled, announce, entryElementStore } =
|
||||
useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const { isSelected, toggleSelectItem } = useBoardSelection();
|
||||
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) => {
|
||||
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
|
||||
@@ -60,15 +65,14 @@ export const FixedGridItem = ({
|
||||
toggleSelectItem(item.id);
|
||||
};
|
||||
const instructionsId = useId();
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready" && !interactionDisabled;
|
||||
const placement = toPlacement(item);
|
||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const keyboardGridRef = useRef<{
|
||||
source: readonly SectionGridPlacement[];
|
||||
current: readonly SectionGridPlacement[];
|
||||
}>({
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: placements,
|
||||
current: placements,
|
||||
});
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
@@ -111,14 +115,14 @@ export const FixedGridItem = ({
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, placements)) {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: placements,
|
||||
current: placements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
const currentPlacements = keyboardGridRef.current.current;
|
||||
const current = currentPlacements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
@@ -130,7 +134,7 @@ export const FixedGridItem = ({
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
placements: currentPlacements,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -138,8 +142,8 @@ export const FixedGridItem = ({
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
source: currentPlacements,
|
||||
current: currentPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
@@ -188,13 +192,22 @@ export const FixedGridItem = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={registerElement}
|
||||
{...semantics}
|
||||
onClickCapture={handleClickCapture}
|
||||
data-board-item-selected={selected ? "true" : undefined}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
||||
className={combineClasses(
|
||||
classes.staticItem,
|
||||
isEditorActive && "board-grid-entry",
|
||||
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-type={item.type}
|
||||
data-kind={item.kind}
|
||||
@@ -209,7 +222,7 @@ export const FixedGridItem = ({
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{children}
|
||||
<div className={combineClasses("board-grid-content-mount", classes.contentMount)}>{children}</div>
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
import type * as GridEditorModule from "./grid-editor";
|
||||
|
||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||
let editorModule: typeof GridEditorModule | undefined;
|
||||
|
||||
export const loadGridEditorAsync = () => {
|
||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
editorModulePromise ??= import("./grid-editor")
|
||||
.then((loadedModule) => {
|
||||
editorModule = loadedModule;
|
||||
return loadedModule;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return editorModulePromise;
|
||||
};
|
||||
|
||||
export const getLoadedGridEditorModule = () => editorModule;
|
||||
|
||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||
let animationFrameId: 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;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
@@ -22,6 +23,13 @@
|
||||
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-drag-source="true"] {
|
||||
z-index: 1000;
|
||||
@@ -101,24 +109,12 @@
|
||||
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 {
|
||||
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 {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
@@ -57,27 +57,18 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import type { GridEditorRenderProps } from "./grid-editor-registry";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
import { GridPreviewLayer } from "./grid-preview-layer";
|
||||
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 { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
|
||||
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 {
|
||||
kind: typeof GRID_ENTRY_TYPE;
|
||||
sectionId: string;
|
||||
@@ -727,14 +718,22 @@ export default function GridEditor({
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
transactionPlacements,
|
||||
className,
|
||||
}: GridEditorProps) {
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
entryElementStore,
|
||||
}: GridEditorRenderProps) {
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||
const interactionStore = useBoardGridEditorInteractionStore();
|
||||
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 ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
const depth = getDepth(sectionId);
|
||||
@@ -769,14 +768,6 @@ export default function GridEditor({
|
||||
},
|
||||
[droppableRef],
|
||||
);
|
||||
const registerEntryElement = useCallback(
|
||||
(id: string, element: HTMLElement | null) => {
|
||||
if (element) entryElements.set(id, element);
|
||||
else entryElements.delete(id);
|
||||
},
|
||||
[entryElements],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = gridRef.current;
|
||||
if (!element) return;
|
||||
@@ -786,11 +777,11 @@ export default function GridEditor({
|
||||
maxRowCount,
|
||||
parentGridId,
|
||||
ownerPlacementId,
|
||||
placements,
|
||||
placements: transactionPlacements,
|
||||
depth,
|
||||
element,
|
||||
});
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -815,7 +806,7 @@ export default function GridEditor({
|
||||
label={labelById.get(placement.id) ?? placement.id}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
registerElement={registerEntryElement}
|
||||
element={entryElements.get(placement.id) ?? null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -844,24 +835,18 @@ const DndGridEntryComponent = ({
|
||||
label,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
registerElement,
|
||||
element,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
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);
|
||||
placementRef.current = placement;
|
||||
const resizeOutlineController = useMemo(
|
||||
() =>
|
||||
createGridResizeOutlineController(
|
||||
() => shellRef.current,
|
||||
() => element,
|
||||
() => placementRef.current,
|
||||
),
|
||||
[],
|
||||
[element],
|
||||
);
|
||||
const sourceData = useMemo<GridEntryData>(
|
||||
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
||||
@@ -882,67 +867,39 @@ const DndGridEntryComponent = ({
|
||||
const modifiers = useMemo(
|
||||
() => [
|
||||
RestrictToElement.configure({
|
||||
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
element: () => element?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
[element],
|
||||
);
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
isDragSource,
|
||||
isDropping,
|
||||
} = useDraggable<GridEntryData>({
|
||||
const { isDragSource, isDropping } = useDraggable<GridEntryData>({
|
||||
id: placement.id,
|
||||
type: GRID_ENTRY_TYPE,
|
||||
data: sourceData,
|
||||
element,
|
||||
handle: element,
|
||||
disabled: element === null,
|
||||
sensors,
|
||||
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]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const container = acquireContainer(placement.id);
|
||||
mount.replaceChildren(container);
|
||||
|
||||
if (!element) return;
|
||||
if (isDragSource) element.dataset.dndDragSource = "true";
|
||||
else delete element.dataset.dndDragSource;
|
||||
if (isDropping) element.dataset.dndDropping = "true";
|
||||
else delete element.dataset.dndDropping;
|
||||
return () => {
|
||||
if (container.parentElement === mount) container.remove();
|
||||
delete element.dataset.dndDragSource;
|
||||
delete element.dataset.dndDropping;
|
||||
};
|
||||
}, [acquireContainer, placement.id, structureRevision]);
|
||||
}, [element, isDragSource, isDropping]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setShellRef}
|
||||
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" />
|
||||
if (!element) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<GridResizeHandles
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
@@ -955,7 +912,9 @@ const DndGridEntryComponent = ({
|
||||
cancelResize={cancelResize}
|
||||
scheduleResizeOutline={resizeOutlineController.schedule}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
element,
|
||||
`board-grid-resize-handles:${placement.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
element: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
|
||||
previous.label === next.label &&
|
||||
previous.columnCount === next.columnCount &&
|
||||
previous.maxRowCount === next.maxRowCount &&
|
||||
previous.registerElement === next.registerElement &&
|
||||
previous.element === next.element &&
|
||||
previous.placement.id === next.placement.id &&
|
||||
previous.placement.type === next.placement.type &&
|
||||
previous.placement.x === next.placement.x &&
|
||||
|
||||
@@ -2,38 +2,23 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/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 { 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";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
structureRevision: string;
|
||||
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Shared board-grid services for editor announcements and integration data. */
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
@@ -41,51 +26,27 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
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 entryRuntimesRef = useRef(new Map<string, unknown>());
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
const liveEntryIds = useMemo(
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
|
||||
|
||||
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 getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
|
||||
const existing = entryRuntimesRef.current.get(entryId);
|
||||
if (existing !== undefined) return existing as TRuntime;
|
||||
const runtime = createRuntime();
|
||||
entryRuntimesRef.current.set(entryId, runtime);
|
||||
return runtime;
|
||||
}, []);
|
||||
|
||||
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
|
||||
useEffect(() => {
|
||||
// A cross-grid move can briefly have no shell, but the entry still exists
|
||||
// on the board. Only remove containers whose board entity was deleted.
|
||||
let didPrune = false;
|
||||
for (const id of containersRef.current.keys()) {
|
||||
if (liveEntryIds.has(id)) continue;
|
||||
containersRef.current.delete(id);
|
||||
didPrune = true;
|
||||
for (const entryId of entryRuntimesRef.current.keys()) {
|
||||
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
|
||||
}
|
||||
if (didPrune) setContainers(new Map(containersRef.current));
|
||||
}, [liveEntryIds]);
|
||||
|
||||
}, [liveItemIds]);
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
structureRevision,
|
||||
}),
|
||||
[acquireContainer, announce, containers, integrations, structureRevision],
|
||||
() => ({ announce, integrations, getEntryRuntime }),
|
||||
[announce, getEntryRuntime, integrations],
|
||||
);
|
||||
|
||||
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 = () => {
|
||||
const context = useContext(GridPortalHostContext);
|
||||
if (!context) throw new Error("BoardGridPortalHost is required");
|
||||
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;
|
||||
}
|
||||
|
||||
.editorPortalHost {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
width 220ms ease,
|
||||
height 220ms ease;
|
||||
}
|
||||
|
||||
.contentMount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -43,19 +47,6 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -67,9 +58,3 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.staticItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
@@ -15,56 +14,21 @@ import {
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
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 } from "../section-collapse";
|
||||
import { SectionProvider, useSectionContext } from "../section-context";
|
||||
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";
|
||||
|
||||
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 {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
@@ -85,21 +49,19 @@ export const SectionGrid = ({
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const editorRegistry = useGridEditorRegistry();
|
||||
const editorHostRef = useRef<HTMLDivElement>(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 minimumBySectionId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
);
|
||||
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(
|
||||
() =>
|
||||
@@ -121,41 +83,112 @@ export const SectionGrid = ({
|
||||
}),
|
||||
[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 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;
|
||||
|
||||
return getCollapsedDisplayLayout(placements, {
|
||||
columnCount,
|
||||
collapsedItemIds: directCollapsedIds,
|
||||
});
|
||||
}, [collapsedSectionIds, columnCount, innerSections, placements]);
|
||||
}, [columnCount, directCollapsedIds, placements]);
|
||||
|
||||
const placementById = useMemo(
|
||||
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
||||
[displayPlacements],
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
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;
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
// 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<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 (
|
||||
<SectionProvider
|
||||
@@ -166,47 +199,48 @@ export const SectionGrid = ({
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements: displayPlacements,
|
||||
interactionDisabled: isInteractionDisabled,
|
||||
announce,
|
||||
entryElementStore,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
style={{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
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-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
|
||||
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
|
||||
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
|
||||
>
|
||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
maxRowCount={maxRowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("board-grid-editor", classes.editorGrid)}
|
||||
/>
|
||||
) : isEditMode && editorRuntimeStatus === "loading" ? (
|
||||
<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
|
||||
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>
|
||||
<div ref={editorHostRef} className={classes.editorPortalHost} />
|
||||
</Box>
|
||||
</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 [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
@@ -225,37 +259,70 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||
};
|
||||
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
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];
|
||||
const containerMinimumSizeCache = new WeakMap<
|
||||
ReturnType<typeof useRequiredBoard>,
|
||||
Map<string, ReadonlyMap<string, { width: number; height: number }>>
|
||||
>();
|
||||
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
||||
const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, layoutId: string) => {
|
||||
const cachedByLayout = containerMinimumSizeCache.get(board);
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
commitSectionGrid: vi.fn(),
|
||||
isSelected: vi.fn(() => false),
|
||||
toggleSelectItem: vi.fn(),
|
||||
registerElement: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
@@ -79,7 +80,17 @@ vi.mock("../../section-context", () => ({
|
||||
innerSections: [],
|
||||
columnCount: 3,
|
||||
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,
|
||||
entryElementStore: { register: mocks.registerElement },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -98,6 +109,7 @@ describe("fixed grid item behavior", () => {
|
||||
beforeEach(() => {
|
||||
mocks.announce.mockReset();
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
mocks.registerElement.mockReset();
|
||||
mocks.editMode = true;
|
||||
mocks.maxRowCount = null;
|
||||
getWeatherMock().advancedOptions.title = null;
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("grid entry memoization", () => {
|
||||
{
|
||||
"data-grid-id": props.placement.id,
|
||||
"data-x": props.placement.x,
|
||||
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
|
||||
},
|
||||
createElement(WidgetProbe),
|
||||
);
|
||||
@@ -79,21 +78,17 @@ describe("grid entry memoization", () => {
|
||||
});
|
||||
|
||||
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const interactionStore = createGridInteractionStore<GridInteraction>();
|
||||
const gridRef = { current: null as HTMLDivElement | null };
|
||||
const setGridRef = (element: HTMLDivElement | null) => {
|
||||
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) => ({
|
||||
...createProps(),
|
||||
registerElement,
|
||||
element: document.createElement("span"),
|
||||
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[]) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
@@ -160,7 +155,7 @@ describe("grid entry memoization", () => {
|
||||
test.each([
|
||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||
["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",
|
||||
(props: DndGridEntryProps) => ({
|
||||
@@ -198,5 +193,5 @@ const createProps = (): DndGridEntryProps => ({
|
||||
label: "Weather",
|
||||
columnCount: 12,
|
||||
maxRowCount: null,
|
||||
registerElement: () => undefined,
|
||||
element: null,
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
|
||||
const gridDirectory = resolve(import.meta.dirname, "..");
|
||||
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
||||
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
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", () => {
|
||||
const source = readFileSync(sectionGridPath, "utf8");
|
||||
test("loads the editor only in edit mode without replacing static board content", () => {
|
||||
const sectionSource = readFileSync(sectionGridPath, "utf8");
|
||||
const boundarySource = readFileSync(editorBoundaryPath, "utf8");
|
||||
|
||||
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||
expect(source).toMatch(
|
||||
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/,
|
||||
);
|
||||
expect(source.match(/<GridEditor\b/g)).toHaveLength(1);
|
||||
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/);
|
||||
expect(sectionSource).not.toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||
expect(sectionSource).toMatch(/<SectionContent\s*\/>/);
|
||||
expect(sectionSource).toMatch(/<div ref={editorHostRef} className={classes\.editorPortalHost}\s*\/>/);
|
||||
expect(boundarySource).toMatch(/loadGridEditorAsync\(\)/);
|
||||
expect(boundarySource).toMatch(/isEditMode\s*&&\s*Provider\s*&&\s*GridEditor/);
|
||||
expect(boundarySource).toMatch(/createPortal\(<GridEditor \{\.\.\.props} \/>, host, props\.sectionId\)/);
|
||||
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
||||
expect(
|
||||
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 {
|
||||
collapsedSectionIds: ReadonlySet<string>;
|
||||
visuallyCollapsedSectionIds: ReadonlySet<string>;
|
||||
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
||||
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
|
||||
}
|
||||
|
||||
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
||||
|
||||
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
||||
@@ -27,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
),
|
||||
);
|
||||
const collapsedByIdRef = useRef(collapsedById);
|
||||
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
collapsedByIdRef.current = collapsedById;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
|
||||
}, [isEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedById((previous) => {
|
||||
const next = { ...previous };
|
||||
@@ -57,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [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") {
|
||||
mutate(
|
||||
@@ -76,47 +90,70 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
[mutate, status],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(
|
||||
() => ({
|
||||
collapsedSectionIds: new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
const expandForEditing = useCallback(
|
||||
(sectionIds: ReadonlySet<string>) => {
|
||||
if (!isEditMode || sectionIds.size === 0) return;
|
||||
setTemporarilyExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
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,
|
||||
}),
|
||||
[collapsedById, setCollapsed],
|
||||
);
|
||||
expandForEditing,
|
||||
};
|
||||
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
|
||||
|
||||
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
||||
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
|
||||
return {
|
||||
isCollapsed,
|
||||
isVisuallyCollapsed: isCollapsed && !isEditMode,
|
||||
isVisuallyCollapsed,
|
||||
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollapsedSectionIds = () => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!context) {
|
||||
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 { 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 {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
@@ -11,7 +13,10 @@ interface SectionContextProps {
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
interactionDisabled: boolean;
|
||||
announce: (message: string) => void;
|
||||
entryElementStore: GridEntryElementStore;
|
||||
}
|
||||
|
||||
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[] } => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
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],
|
||||
);
|
||||
const index = useMemo(() => getSectionItemsIndex(board, currentLayoutId), [board, currentLayoutId]);
|
||||
|
||||
return {
|
||||
innerSections,
|
||||
items,
|
||||
innerSections: index.innerSectionsByParentId.get(sectionId) ?? EMPTY_INNER_SECTIONS,
|
||||
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;
|
||||
};
|
||||
Reference in new issue
Block a user