diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index b1d6b8048..368f86207 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -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(); - const widgetStateRef = useRef | 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 | null>, + widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef, +}); + interface LoadedBoardItemContentProps { item: SectionItem; width: number; diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx index 02ee5a1c6..ef5511160 100644 --- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx +++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx @@ -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; @@ -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; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor-registry.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor-registry.tsx index f10223fde..aa7839e65 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor-registry.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor-registry.tsx @@ -46,6 +46,7 @@ export interface GridEditorRenderProps { rowCount: number; maxRowCount: number | null; placements: readonly SectionGridPlacement[]; + transactionPlacements: readonly SectionGridPlacement[]; className: string; section: Exclude | ContainerSectionItem; items: SectionItem[]; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 27e9f71d0..f8c5c4b49 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -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 (
void; integrations: RouterOutputs["integration"]["all"] | undefined; + getEntryRuntime: (entryId: string, createRuntime: () => TRuntime) => TRuntime; } const GridPortalHostContext = createContext(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()); const announce = useCallback((message: string) => { setAnnouncement((previous) => ({ id: previous.id + 1, message })); }, []); + const getEntryRuntime = useCallback((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( - () => ({ announce, integrations }), - [announce, integrations], + () => ({ announce, integrations, getEntryRuntime }), + [announce, getEntryRuntime, integrations], ); return ( diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index 83a4f7726..73260ba13 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -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) => { + if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return; + const target = event.target; + if (!(target instanceof Element)) return; + const entry = target.closest('[data-editor-grid-entry="true"]'); + if (!entry || !event.currentTarget.contains(entry)) return; + if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return; + expandSectionsForEditing(directCollapsedIds); + }; + const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent) => { + if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return; + const target = event.target; + if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return; + expandSectionsForEditing(directCollapsedIds); + }; useLayoutEffect(() => { const host = editorHostRef.current; @@ -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 = ({ { const [visualHeight, setVisualHeight] = useState(0); diff --git a/apps/nextjs/src/components/board/sections/section-collapse.tsx b/apps/nextjs/src/components/board/sections/section-collapse.tsx index da27416c2..6d2492e2f 100644 --- a/apps/nextjs/src/components/board/sections/section-collapse.tsx +++ b/apps/nextjs/src/components/board/sections/section-collapse.tsx @@ -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; + visuallyCollapsedSectionIds: ReadonlySet; setCollapsed: (sectionId: string, collapsed: boolean) => void; + expandForEditing: (sectionIds: ReadonlySet) => void; } const SectionCollapseContext = createContext(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>(() => @@ -26,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => ), ); const collapsedByIdRef = useRef(collapsedById); + const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState>(() => 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( - () => ({ - collapsedSectionIds: new Set( - Object.entries(collapsedById) - .filter(([, collapsed]) => collapsed) - .map(([sectionId]) => sectionId), + const expandForEditing = useCallback( + (sectionIds: ReadonlySet) => { + 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(() => { + 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 {children}; }; @@ -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; };