fix(board): harden grid edit transitions
This commit is contained in:
7 files changed
+135
-43
No files matched your search
@@ -32,6 +32,7 @@ import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
@@ -105,8 +106,8 @@ const WidgetDefinitionLoadError = ({
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
const { getEntryRuntime } = useBoardGridPortalHost();
|
||||
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
|
||||
const width = measuredWidth || getLogicalTrackSize(item.width);
|
||||
const height = measuredHeight || getLogicalTrackSize(item.height);
|
||||
|
||||
@@ -137,6 +138,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const createBoardItemRuntime = () => ({
|
||||
widgetStateRef: { current: null } as MutableRefObject<Record<string, unknown> | null>,
|
||||
widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef,
|
||||
});
|
||||
|
||||
interface LoadedBoardItemContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
|
||||
@@ -121,8 +121,8 @@ export const FixedGridItem = ({
|
||||
current: placements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
const currentPlacements = keyboardGridRef.current.current;
|
||||
const current = currentPlacements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
@@ -134,7 +134,7 @@ export const FixedGridItem = ({
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
placements: currentPlacements,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -142,8 +142,8 @@ export const FixedGridItem = ({
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: placements,
|
||||
current: placements,
|
||||
source: currentPlacements,
|
||||
current: currentPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface GridEditorRenderProps {
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
transactionPlacements: readonly SectionGridPlacement[];
|
||||
className: string;
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
items: SectionItem[];
|
||||
|
||||
@@ -718,6 +718,7 @@ export default function GridEditor({
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
transactionPlacements,
|
||||
className,
|
||||
section,
|
||||
items,
|
||||
@@ -776,11 +777,11 @@ export default function GridEditor({
|
||||
maxRowCount,
|
||||
parentGridId,
|
||||
ownerPlacementId,
|
||||
placements,
|
||||
placements: transactionPlacements,
|
||||
depth,
|
||||
element,
|
||||
});
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,35 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/** Shared board-grid services for editor announcements and integration data. */
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||
const entryRuntimesRef = useRef(new Map<string, unknown>());
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
|
||||
const existing = entryRuntimesRef.current.get(entryId);
|
||||
if (existing !== undefined) return existing as TRuntime;
|
||||
const runtime = createRuntime();
|
||||
entryRuntimesRef.current.set(entryId, runtime);
|
||||
return runtime;
|
||||
}, []);
|
||||
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
|
||||
useEffect(() => {
|
||||
for (const entryId of entryRuntimesRef.current.keys()) {
|
||||
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
|
||||
}
|
||||
}, [liveItemIds]);
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({ announce, integrations }),
|
||||
[announce, integrations],
|
||||
() => ({ announce, integrations, getEntryRuntime }),
|
||||
[announce, getEntryRuntime, integrations],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
@@ -23,7 +23,7 @@ import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { SectionContent } from "../content";
|
||||
import { useCollapsedSectionIds } from "../section-collapse";
|
||||
import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
@@ -57,13 +57,11 @@ export const SectionGrid = ({
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const { announce, integrations } = useBoardGridPortalHost();
|
||||
const collapsedSectionIds = useCollapsedSectionIds();
|
||||
const minimumBySectionId = useMemo(
|
||||
() => {
|
||||
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
|
||||
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
|
||||
},
|
||||
[board, currentLayoutId, innerSections],
|
||||
);
|
||||
const expandSectionsForEditing = useExpandSectionsForEditing();
|
||||
const minimumBySectionId = useMemo(() => {
|
||||
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
|
||||
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
|
||||
}, [board, currentLayoutId, innerSections]);
|
||||
|
||||
const placements = useMemo(
|
||||
() =>
|
||||
@@ -86,7 +84,10 @@ export const SectionGrid = ({
|
||||
[columnCount, innerSections, items, minimumBySectionId],
|
||||
);
|
||||
const collapsibleSectionIds = useMemo(
|
||||
() => new Set(innerSections.filter((section) => section.options.collapsible).map((section) => section.id)),
|
||||
() =>
|
||||
new Set(
|
||||
innerSections.filter((innerSection) => innerSection.options.collapsible).map((innerSection) => innerSection.id),
|
||||
),
|
||||
[innerSections],
|
||||
);
|
||||
|
||||
@@ -131,15 +132,29 @@ export const SectionGrid = ({
|
||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
// Collapsed display geometry is intentionally transient and must never be
|
||||
// committed as the expanded layout. Expanding explicitly enables editing.
|
||||
const isInteractionDisabled =
|
||||
directCollapsedIds.size > 0 || (section.kind === "container" && collapsedSectionIds.has(section.id));
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
const canvasAttributes =
|
||||
isEditMode && !isInteractionDisabled
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
const editorClassName = combineClasses("board-grid-editor", classes.editorGrid);
|
||||
const expandCollapsedSectionsForPointerEdit = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const entry = target.closest('[data-editor-grid-entry="true"]');
|
||||
if (!entry || !event.currentTarget.contains(entry)) return;
|
||||
if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return;
|
||||
expandSectionsForEditing(directCollapsedIds);
|
||||
};
|
||||
const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return;
|
||||
expandSectionsForEditing(directCollapsedIds);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = editorHostRef.current;
|
||||
@@ -156,6 +171,7 @@ export const SectionGrid = ({
|
||||
rowCount,
|
||||
maxRowCount,
|
||||
placements: displayPlacements,
|
||||
transactionPlacements: placements,
|
||||
className: editorClassName,
|
||||
entryElementStore,
|
||||
});
|
||||
@@ -169,6 +185,7 @@ export const SectionGrid = ({
|
||||
entryElementStore,
|
||||
isInteractionDisabled,
|
||||
maxRowCount,
|
||||
placements,
|
||||
rowCount,
|
||||
section,
|
||||
]);
|
||||
@@ -191,15 +208,19 @@ export const SectionGrid = ({
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
style={{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties}
|
||||
style={
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
|
||||
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
|
||||
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
|
||||
>
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
@@ -216,6 +237,10 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
const [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
|
||||
@@ -6,18 +6,22 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from "./section-collapse-storage";
|
||||
|
||||
interface SectionCollapseContextValue {
|
||||
collapsedSectionIds: ReadonlySet<string>;
|
||||
visuallyCollapsedSectionIds: ReadonlySet<string>;
|
||||
setCollapsed: (sectionId: string, collapsed: boolean) => void;
|
||||
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
|
||||
}
|
||||
|
||||
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
|
||||
|
||||
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
|
||||
@@ -26,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
),
|
||||
);
|
||||
const collapsedByIdRef = useRef(collapsedById);
|
||||
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
collapsedByIdRef.current = collapsedById;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
|
||||
}, [isEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedById((previous) => {
|
||||
const next = { ...previous };
|
||||
@@ -56,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
|
||||
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
|
||||
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
|
||||
setTemporarilyExpandedIds((current) => {
|
||||
if (!current.has(sectionId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(sectionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (status === "authenticated") {
|
||||
mutate(
|
||||
@@ -75,17 +90,33 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
|
||||
[mutate, status],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(
|
||||
() => ({
|
||||
collapsedSectionIds: new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
const expandForEditing = useCallback(
|
||||
(sectionIds: ReadonlySet<string>) => {
|
||||
if (!isEditMode || sectionIds.size === 0) return;
|
||||
setTemporarilyExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const sectionId of sectionIds) next.add(sectionId);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
},
|
||||
[isEditMode],
|
||||
);
|
||||
|
||||
const value = useMemo<SectionCollapseContextValue>(() => {
|
||||
const collapsedSectionIds = new Set(
|
||||
Object.entries(collapsedById)
|
||||
.filter(([, collapsed]) => collapsed)
|
||||
.map(([sectionId]) => sectionId),
|
||||
);
|
||||
return {
|
||||
collapsedSectionIds,
|
||||
visuallyCollapsedSectionIds: new Set(
|
||||
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
|
||||
),
|
||||
setCollapsed,
|
||||
}),
|
||||
[collapsedById, setCollapsed],
|
||||
);
|
||||
expandForEditing,
|
||||
};
|
||||
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
|
||||
|
||||
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
|
||||
};
|
||||
@@ -98,11 +129,12 @@ export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: stri
|
||||
}
|
||||
|
||||
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
|
||||
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
|
||||
return {
|
||||
isCollapsed,
|
||||
isVisuallyCollapsed: isCollapsed,
|
||||
isVisuallyCollapsed,
|
||||
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
|
||||
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -113,5 +145,15 @@ export const useCollapsedSectionIds = () => {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
return context.collapsedSectionIds;
|
||||
return context.visuallyCollapsedSectionIds;
|
||||
};
|
||||
|
||||
export const useExpandSectionsForEditing = () => {
|
||||
const context = useContext(SectionCollapseContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("BoardSectionCollapseProvider is required");
|
||||
}
|
||||
|
||||
return context.expandForEditing;
|
||||
};
|
||||
Reference in new issue
Block a user