From 16982aa178d4d433a73fac6b4ecee2374561a88e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 11 Aug 2026 15:09:53 +0200 Subject: [PATCH] fix: simplify grid interaction previews --- .../board/sections/grid/grid-drag-handle.ts | 25 ++ .../board/sections/grid/grid-editor.css | 4 +- .../board/sections/grid/grid-editor.tsx | 265 +++++------------- .../sections/grid/grid-preview-geometry.ts | 14 +- .../sections/grid/grid-preview-layer.tsx | 136 +++++++++ .../sections/grid/grid-resize-outline.ts | 12 +- .../grid/test/grid-drag-handle.spec.ts | 33 +++ .../grid/test/grid-entry-memo.spec.tsx | 67 ++++- e2e/board-grid.spec.ts | 2 + 9 files changed, 341 insertions(+), 217 deletions(-) create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-drag-handle.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/test/grid-drag-handle.spec.ts diff --git a/apps/nextjs/src/components/board/sections/grid/grid-drag-handle.ts b/apps/nextjs/src/components/board/sections/grid/grid-drag-handle.ts new file mode 100644 index 000000000..10e15de21 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-drag-handle.ts @@ -0,0 +1,25 @@ +export const observeGridDragHandle = ( + container: HTMLElement, + selector: string, + bind: (element: HTMLElement | null) => void, +) => { + let observer: MutationObserver | null = null; + const bindFirstHandle = () => { + const handle = container.querySelector(selector); + if (!handle) return false; + bind(handle); + observer?.disconnect(); + observer = null; + return true; + }; + + if (!bindFirstHandle()) { + observer = new MutationObserver(bindFirstHandle); + observer.observe(container, { childList: true, subtree: true }); + } + + return () => { + observer?.disconnect(); + bind(null); + }; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 89635831d..edf69e044 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -75,8 +75,8 @@ zoom: var(--board-grid-overlay-content-zoom, 1); } -body[data-board-grid-interacting="drag"] .board-grid-entry, -body[data-board-grid-interacting="drag"] .board-grid-placeholder { +body[data-board-grid-interacting] .board-grid-entry, +body[data-board-grid-interacting] .board-grid-placeholder { transition: none; } 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 8167f2946..fb21151e5 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react"; +import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react"; import { createContext, memo, @@ -36,12 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; -import { - getLayoutRowCount, - getLogicalItemStyle, - getLogicalTrackSize, - LOGICAL_GRID_PITCH, -} from "~/components/board/layout"; +import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; import { useSectionContext } from "../section-context"; import { @@ -61,9 +56,11 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa import type { DndGridEntryProps } from "./grid-entry-memo"; import { areDndGridEntryPropsEqual } from "./grid-entry-memo"; import { getGridDepth } from "./grid-depth"; +import { observeGridDragHandle } from "./grid-drag-handle"; import { createGridInteractionStore } from "./grid-interaction-store"; import { useBoardGridPortalHost } from "./grid-portal-host"; -import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry"; +import type { GridInteraction } from "./grid-preview-layer"; +import { GridPreviewLayer } from "./grid-preview-layer"; import { createGridResizeOutlineController } from "./grid-resize-outline"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions"; @@ -103,17 +100,6 @@ interface RegisteredGrid { element: HTMLElement; } -interface GridInteraction { - activeId: string; - sourceGridId: string; - targetGridId: string | null; - targetPlacement: SectionGridPlacement | null; - state: TransactionalGridState; - mode: "drag" | "resize"; - valid: boolean; - previewRevision: number; -} - interface ActiveTransaction { transaction: GridTransaction; label: string; @@ -173,13 +159,6 @@ const useBoardGridEditorInteraction = () => { return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); }; -const useBoardGridEditorGridInteraction = (sectionId: string) => { - const store = useBoardGridEditorInteractionStore(); - const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]); - const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -}; - /** One provider coordinates every root and nested grid on the board. */ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { const board = useRequiredBoard(); @@ -716,10 +695,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { return data ?
{data.label}
: null; }} - + @@ -727,27 +703,14 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { }; const GridInteractionEffects = ({ - cancelActiveInteraction, syncRenderedInteraction, }: { - cancelActiveInteraction: () => void; syncRenderedInteraction: (interaction: GridInteraction | null) => void; }) => { const interaction = useBoardGridEditorInteraction(); useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]); - useEffect(() => { - if (interaction?.mode !== "resize") return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - cancelActiveInteraction(); - }; - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [cancelActiveInteraction, interaction?.mode]); - return ; }; @@ -775,8 +738,9 @@ export default function GridEditor({ const t = useI18n(); const { section, items, innerSections } = useSectionContext(); const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions(); + const interactionStore = useBoardGridEditorInteractionStore(); const gridRef = useRef(null); - const entryElementsRef = useRef(new Map()); + const [entryElements] = useState(() => new Map()); const parentGridId = "parentSectionId" in section ? section.parentSectionId : null; const ownerPlacementId = "parentSectionId" in section ? section.id : null; const depth = getDepth(sectionId); @@ -811,10 +775,13 @@ export default function GridEditor({ }, [droppableRef], ); - const registerEntryElement = useCallback((id: string, element: HTMLElement | null) => { - if (element) entryElementsRef.current.set(id, element); - else entryElementsRef.current.delete(id); - }, []); + const registerEntryElement = useCallback( + (id: string, element: HTMLElement | null) => { + if (element) entryElements.set(id, element); + else entryElements.delete(id); + }, + [entryElements], + ); useLayoutEffect(() => { const element = gridRef.current; @@ -864,7 +831,8 @@ export default function GridEditor({ maxRowCount={maxRowCount} placements={placements} gridRef={gridRef} - entryElementsRef={entryElementsRef} + entryElements={entryElements} + interactionStore={interactionStore} /> ; - entryElementsRef: RefObject>; -} - -const GridPreviewLayer = ({ - sectionId, - rowCount, - maxRowCount, - placements, - gridRef, - entryElementsRef, -}: GridPreviewLayerProps) => { - const interaction = useBoardGridEditorGridInteraction(sectionId); - const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId); - const previewStateRef = useRef(createGridPreviewDomState()); - const placementsRef = useRef(placements); - placementsRef.current = placements; - const targetPlacement = - interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; - const growsDuringDrag = - interaction?.mode === "drag" && - (interaction.targetGridId === sectionId || - (interaction.targetGridId === null && interaction.sourceGridId === sectionId)); - const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements); - const renderedRowCount = - maxRowCount === null && interaction ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount; - - useLayoutEffect(() => { - previewStateRef.current = syncGridPreviewGeometry({ - elements: entryElementsRef.current, - placements, - previewPlacements: previewGrid?.placements ?? null, - activeId: interaction?.activeId ?? null, - mode: interaction?.mode ?? null, - previous: previewStateRef.current, - }); - - const grid = gridRef.current; - if (!grid) return; - const isIntentTarget = interaction?.mode === "drag" && interaction.targetGridId === sectionId; - setOptionalAttribute(grid, "data-dnd-drop-target", isIntentTarget ? "true" : "false"); - setOptionalAttribute(grid, "data-dnd-drop-valid", isIntentTarget ? String(targetPlacement !== null) : null); - setOptionalAttribute( - grid, - "data-dnd-preview-revision", - interaction?.targetGridId === sectionId ? String(interaction.previewRevision) : null, - ); - grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`; - - const viewport = grid.parentElement; - if (viewport?.hasAttribute("data-section-id")) { - viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`); - } - }, [ - entryElementsRef, - gridRef, - interaction, - placements, - previewGrid?.placements, - renderedRowCount, - sectionId, - targetPlacement, - ]); - - useLayoutEffect( - () => () => { - clearGridPreviewGeometry(entryElementsRef.current, placementsRef.current, previewStateRef.current); - const grid = gridRef.current; - if (!grid) return; - grid.removeAttribute("data-dnd-drop-target"); - grid.removeAttribute("data-dnd-drop-valid"); - grid.removeAttribute("data-dnd-preview-revision"); - grid.parentElement?.style.removeProperty("--board-grid-drag-height"); - }, - [entryElementsRef, gridRef], - ); - - return targetPlacement ? ( -