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 { 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;
|
||||||
|
};
|
||||||
Reference in new issue
Block a user