Merge commit 'f9a457982' into feat/auto-mobile-boards
This commit is contained in:
6 files changed
+434
-140
No files matched your search
@@ -140,6 +140,14 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
|
||||
|
||||
@@ -47,7 +58,10 @@ import {
|
||||
previewGridResize,
|
||||
} from "./dnd";
|
||||
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
@@ -124,8 +138,7 @@ interface ResizePreviewInput {
|
||||
minHeight: number;
|
||||
}
|
||||
|
||||
interface BoardGridEditorContextValue {
|
||||
interaction: GridInteraction | null;
|
||||
interface BoardGridEditorActionsContextValue {
|
||||
registerGrid: (grid: RegisteredGrid) => () => void;
|
||||
getDepth: (gridId: string) => number;
|
||||
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
|
||||
@@ -136,14 +149,35 @@ interface BoardGridEditorContextValue {
|
||||
cancelResize: () => void;
|
||||
}
|
||||
|
||||
const BoardGridEditorContext = createContext<BoardGridEditorContextValue | null>(null);
|
||||
const BoardGridEditorActionsContext = createContext<BoardGridEditorActionsContextValue | null>(null);
|
||||
const BoardGridEditorInteractionContext = createContext<ReturnType<
|
||||
typeof createGridInteractionStore<GridInteraction>
|
||||
> | null>(null);
|
||||
|
||||
const useBoardGridEditor = () => {
|
||||
const value = useContext(BoardGridEditorContext);
|
||||
const useBoardGridEditorActions = () => {
|
||||
const value = useContext(BoardGridEditorActionsContext);
|
||||
if (!value) throw new Error("BoardGridEditorProvider is required");
|
||||
return value;
|
||||
};
|
||||
|
||||
const useBoardGridEditorInteractionStore = () => {
|
||||
const store = useContext(BoardGridEditorInteractionContext);
|
||||
if (!store) throw new Error("BoardGridEditorProvider is required");
|
||||
return store;
|
||||
};
|
||||
|
||||
const useBoardGridEditorInteraction = () => {
|
||||
const store = useBoardGridEditorInteractionStore();
|
||||
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();
|
||||
@@ -155,7 +189,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
const activeRef = useRef<ActiveTransaction | null>(null);
|
||||
const renderedInteractionRef = useRef<GridInteraction | null>(null);
|
||||
const collisionFrameRef = useRef(0);
|
||||
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
|
||||
const [interactionStore] = useState(() => createGridInteractionStore<GridInteraction>());
|
||||
const [overlayZoom, setOverlayZoom] = useState(1);
|
||||
const parentGridById = useMemo(
|
||||
() =>
|
||||
@@ -176,21 +210,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
collisionFrameRef.current = 0;
|
||||
activeRef.current = null;
|
||||
renderedInteractionRef.current = null;
|
||||
setInteraction(null);
|
||||
interactionStore.publish(null);
|
||||
document.body.removeAttribute("data-board-grid-interacting");
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const active = activeRef.current;
|
||||
renderedInteractionRef.current =
|
||||
interaction &&
|
||||
active &&
|
||||
interaction.mode === active.mode &&
|
||||
interaction.activeId === active.transaction.activeId &&
|
||||
interaction.sourceGridId === active.transaction.sourceGridId
|
||||
? interaction
|
||||
: null;
|
||||
}, [interaction]);
|
||||
}, [interactionStore]);
|
||||
|
||||
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
|
||||
|
||||
@@ -265,7 +287,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
previewRevision: 0,
|
||||
};
|
||||
document.body.setAttribute("data-board-grid-interacting", mode);
|
||||
setInteraction({
|
||||
interactionStore.publish({
|
||||
activeId,
|
||||
sourceGridId,
|
||||
targetGridId: mode === "resize" ? sourceGridId : null,
|
||||
@@ -282,7 +304,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[announce, clearInteraction, getCurrentState, t],
|
||||
[announce, clearInteraction, getCurrentState, interactionStore, t],
|
||||
);
|
||||
|
||||
const updateDragPreview = useCallback(
|
||||
@@ -297,7 +319,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
const publishPreview = (preview: Omit<GridInteraction, "previewRevision">) => {
|
||||
active.previewRevision += 1;
|
||||
setInteraction({ ...preview, previewRevision: active.previewRevision });
|
||||
interactionStore.publish({ ...preview, previewRevision: active.previewRevision });
|
||||
};
|
||||
|
||||
const showInvalidPreview = (targetGridId: string | null, previewKey: string) => {
|
||||
@@ -406,7 +428,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
valid: true,
|
||||
});
|
||||
},
|
||||
[canAcceptDrop, isDropTargetEligible],
|
||||
[canAcceptDrop, interactionStore, isDropTargetEligible],
|
||||
);
|
||||
|
||||
const commitActiveTransaction = useCallback(
|
||||
@@ -573,49 +595,52 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
[beginTransaction],
|
||||
);
|
||||
|
||||
const applyResizePreview = useCallback((input: ResizePreviewInput) => {
|
||||
const active = activeRef.current;
|
||||
if (!active) return false;
|
||||
active.mode = "resize";
|
||||
active.targetGridId = active.transaction.sourceGridId;
|
||||
const applyResizePreview = useCallback(
|
||||
(input: ResizePreviewInput) => {
|
||||
const active = activeRef.current;
|
||||
if (!active) return false;
|
||||
active.mode = "resize";
|
||||
active.targetGridId = active.transaction.sourceGridId;
|
||||
|
||||
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
|
||||
if (active.lastPreviewKey === key) return active.valid;
|
||||
active.lastPreviewKey = key;
|
||||
const result = previewGridResize(active.transaction, input);
|
||||
active.valid = result.accepted;
|
||||
if (!result.accepted) {
|
||||
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
|
||||
if (active.lastPreviewKey === key) return active.valid;
|
||||
active.lastPreviewKey = key;
|
||||
const result = previewGridResize(active.transaction, input);
|
||||
active.valid = result.accepted;
|
||||
if (!result.accepted) {
|
||||
active.previewRevision += 1;
|
||||
interactionStore.publish({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: active.transaction.sourceGridId,
|
||||
targetPlacement: null,
|
||||
state: cancelGridTransaction(active.transaction),
|
||||
mode: "resize",
|
||||
valid: false,
|
||||
previewRevision: active.previewRevision,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
active.transaction = result.transaction;
|
||||
active.previewRevision += 1;
|
||||
setInteraction({
|
||||
interactionStore.publish({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: active.transaction.sourceGridId,
|
||||
targetPlacement: null,
|
||||
state: cancelGridTransaction(active.transaction),
|
||||
targetPlacement:
|
||||
result.transaction.preview.grids
|
||||
.find((grid) => grid.id === active.transaction.sourceGridId)
|
||||
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "resize",
|
||||
valid: false,
|
||||
valid: true,
|
||||
previewRevision: active.previewRevision,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
active.transaction = result.transaction;
|
||||
active.previewRevision += 1;
|
||||
setInteraction({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: active.transaction.sourceGridId,
|
||||
targetPlacement:
|
||||
result.transaction.preview.grids
|
||||
.find((grid) => grid.id === active.transaction.sourceGridId)
|
||||
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "resize",
|
||||
valid: true,
|
||||
previewRevision: active.previewRevision,
|
||||
});
|
||||
return true;
|
||||
}, []);
|
||||
return true;
|
||||
},
|
||||
[interactionStore],
|
||||
);
|
||||
|
||||
const previewResize = useCallback(
|
||||
(input: ResizePreviewInput) => {
|
||||
@@ -626,9 +651,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
[applyResizePreview],
|
||||
);
|
||||
|
||||
const value = useMemo<BoardGridEditorContextValue>(
|
||||
const actions = useMemo<BoardGridEditorActionsContextValue>(
|
||||
() => ({
|
||||
interaction,
|
||||
registerGrid,
|
||||
getDepth,
|
||||
isDropTargetEligible,
|
||||
@@ -643,13 +667,74 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
canAcceptDrop,
|
||||
cancelActiveInteraction,
|
||||
commitActiveTransaction,
|
||||
interaction,
|
||||
isDropTargetEligible,
|
||||
previewResize,
|
||||
getDepth,
|
||||
registerGrid,
|
||||
],
|
||||
);
|
||||
|
||||
const syncRenderedInteraction = useCallback((interaction: GridInteraction | null) => {
|
||||
const active = activeRef.current;
|
||||
renderedInteractionRef.current =
|
||||
interaction &&
|
||||
active &&
|
||||
interaction.mode === active.mode &&
|
||||
interaction.activeId === active.transaction.activeId &&
|
||||
interaction.sourceGridId === active.transaction.sourceGridId
|
||||
? interaction
|
||||
: null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BoardGridEditorActionsContext.Provider value={actions}>
|
||||
<BoardGridEditorInteractionContext.Provider value={interactionStore}>
|
||||
<DragDropProvider
|
||||
plugins={configureEditorPlugins}
|
||||
onCollision={handleCollision}
|
||||
onBeforeDragStart={handleBeforeDragStart}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay
|
||||
className="board-grid-drag-overlay"
|
||||
dropAnimation={null}
|
||||
style={
|
||||
{
|
||||
zoom: overlayZoom,
|
||||
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{(source) => {
|
||||
const data = getGridEntryData(source);
|
||||
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
<GridInteractionEffects
|
||||
cancelActiveInteraction={cancelActiveInteraction}
|
||||
syncRenderedInteraction={syncRenderedInteraction}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
</BoardGridEditorInteractionContext.Provider>
|
||||
</BoardGridEditorActionsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -661,48 +746,17 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [cancelActiveInteraction, interaction?.mode]);
|
||||
|
||||
return (
|
||||
<BoardGridEditorContext.Provider value={value}>
|
||||
<DragDropProvider
|
||||
plugins={configureEditorPlugins}
|
||||
onCollision={handleCollision}
|
||||
onBeforeDragStart={handleBeforeDragStart}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay
|
||||
className="board-grid-drag-overlay"
|
||||
dropAnimation={null}
|
||||
style={
|
||||
{
|
||||
zoom: overlayZoom,
|
||||
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{(source) => {
|
||||
const data = getGridEntryData(source);
|
||||
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
<GridCollisionSynchronizer interaction={interaction} />
|
||||
</DragDropProvider>
|
||||
</BoardGridEditorContext.Provider>
|
||||
);
|
||||
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
|
||||
};
|
||||
|
||||
const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
|
||||
const GridCollisionSynchronizer = ({ previewRevision }: { previewRevision: number | null }) => {
|
||||
const manager = useDragDropManager();
|
||||
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!geometryKey || !manager) return;
|
||||
if (previewRevision === null || !manager) return;
|
||||
for (const target of manager.registry.droppables.value) target.refreshShape();
|
||||
manager.collisionObserver.forceUpdate(true);
|
||||
}, [geometryKey, manager]);
|
||||
}, [manager, previewRevision]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -718,7 +772,8 @@ export default function GridEditor({
|
||||
}: GridEditorProps) {
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
|
||||
const interaction = useBoardGridEditorGridInteraction(sectionId);
|
||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
@@ -738,9 +793,17 @@ export default function GridEditor({
|
||||
}
|
||||
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
|
||||
}, [controlledById, interaction, placements, previewGrid, sectionId]);
|
||||
const entryById = useMemo(
|
||||
() => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])),
|
||||
[innerSections, items],
|
||||
const labelById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...items, ...innerSections].map((entry) => [
|
||||
entry.id,
|
||||
entry.type === "item"
|
||||
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
|
||||
: entry.options.title.trim() || t("section.container.untitled"),
|
||||
]),
|
||||
),
|
||||
[innerSections, items, t],
|
||||
);
|
||||
const targetPlacement =
|
||||
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
|
||||
@@ -819,20 +882,15 @@ export default function GridEditor({
|
||||
}
|
||||
>
|
||||
{renderPlacements.map((placement) => {
|
||||
const entry = entryById.get(placement.id);
|
||||
const label = entry
|
||||
? entry.type === "item"
|
||||
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
|
||||
: entry.options.title.trim() || t("section.container.untitled")
|
||||
: placement.id;
|
||||
return (
|
||||
<DndGridEntry
|
||||
key={placement.id}
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
label={labelById.get(placement.id) ?? placement.id}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
isActive={interaction?.activeId === placement.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -859,17 +917,16 @@ export default function GridEditor({
|
||||
);
|
||||
}
|
||||
|
||||
interface DndGridEntryProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => {
|
||||
const DndGridEntryComponent = ({
|
||||
sectionId,
|
||||
placement,
|
||||
label,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
isActive,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer } = useBoardGridPortalHost();
|
||||
const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||
const [continuousResize, setContinuousResize] = useState<{
|
||||
placement: SectionGridPlacement;
|
||||
@@ -963,7 +1020,6 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
}, [placement.type]);
|
||||
|
||||
const isActive = interaction?.activeId === placement.id;
|
||||
const style = getLogicalItemStyle(placement) as CSSProperties;
|
||||
const continuousResizeStyle =
|
||||
continuousResize && isActive
|
||||
@@ -1004,7 +1060,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
|
||||
disabled={placement.type === "section" && !isSectionChromeActive}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
beginResize={beginResize}
|
||||
@@ -1017,6 +1073,8 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
);
|
||||
};
|
||||
|
||||
const DndGridEntry = memo(DndGridEntryComponent, areDndGridEntryPropsEqual);
|
||||
|
||||
interface GridResizeHandlesProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
@@ -1024,10 +1082,10 @@ interface GridResizeHandlesProps {
|
||||
disabled: boolean;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
beginResize: BoardGridEditorContextValue["beginResize"];
|
||||
previewResize: BoardGridEditorContextValue["previewResize"];
|
||||
completeResize: BoardGridEditorContextValue["completeResize"];
|
||||
cancelResize: BoardGridEditorContextValue["cancelResize"];
|
||||
beginResize: BoardGridEditorActionsContextValue["beginResize"];
|
||||
previewResize: BoardGridEditorActionsContextValue["previewResize"];
|
||||
completeResize: BoardGridEditorActionsContextValue["completeResize"];
|
||||
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
|
||||
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
|
||||
}
|
||||
|
||||
@@ -1355,18 +1413,3 @@ const getGridTargetData = (target: { data: Record<string, unknown> }): GridTarge
|
||||
const data = target.data as Partial<GridTargetData>;
|
||||
return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null;
|
||||
};
|
||||
|
||||
const getGridGeometryKey = (state: TransactionalGridState<SectionGridPlacement>) =>
|
||||
JSON.stringify(
|
||||
state.grids
|
||||
.map((grid) => ({
|
||||
id: grid.id,
|
||||
columnCount: grid.columnCount,
|
||||
maxRowCount: grid.maxRowCount,
|
||||
parentGridId: grid.parentGridId,
|
||||
placements: grid.placements
|
||||
.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }))
|
||||
.toSorted((first, second) => first.id.localeCompare(second.id)),
|
||||
}))
|
||||
.toSorted((first, second) => first.id.localeCompare(second.id)),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
export interface DndGridEntryProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||
previous.sectionId === next.sectionId &&
|
||||
previous.label === next.label &&
|
||||
previous.columnCount === next.columnCount &&
|
||||
previous.maxRowCount === next.maxRowCount &&
|
||||
previous.isActive === next.isActive &&
|
||||
previous.placement.id === next.placement.id &&
|
||||
previous.placement.type === next.placement.type &&
|
||||
previous.placement.x === next.placement.x &&
|
||||
previous.placement.y === next.placement.y &&
|
||||
previous.placement.w === next.placement.w &&
|
||||
previous.placement.h === next.placement.h &&
|
||||
previous.placement.minW === next.placement.minW &&
|
||||
previous.placement.minH === next.placement.minH;
|
||||
@@ -0,0 +1,58 @@
|
||||
interface GridScopedInteraction {
|
||||
sourceGridId: string;
|
||||
targetGridId: string | null;
|
||||
}
|
||||
|
||||
export interface GridInteractionStore<TInteraction extends GridScopedInteraction> {
|
||||
getSnapshot: () => TInteraction | null;
|
||||
getGridSnapshot: (sectionId: string) => TInteraction | null;
|
||||
publish: (interaction: TInteraction | null) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
subscribeGrid: (sectionId: string, listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
const getAffectedGridIds = (interaction: GridScopedInteraction | null) => {
|
||||
const ids = new Set<string>();
|
||||
if (interaction) {
|
||||
ids.add(interaction.sourceGridId);
|
||||
if (interaction.targetGridId) ids.add(interaction.targetGridId);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const createGridInteractionStore = <
|
||||
TInteraction extends GridScopedInteraction,
|
||||
>(): GridInteractionStore<TInteraction> => {
|
||||
let snapshot: TInteraction | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const gridListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getGridSnapshot: (sectionId) =>
|
||||
snapshot && (snapshot.sourceGridId === sectionId || snapshot.targetGridId === sectionId) ? snapshot : null,
|
||||
publish: (interaction) => {
|
||||
if (Object.is(snapshot, interaction)) return;
|
||||
const affectedGridIds = getAffectedGridIds(snapshot);
|
||||
for (const sectionId of getAffectedGridIds(interaction)) affectedGridIds.add(sectionId);
|
||||
snapshot = interaction;
|
||||
for (const listener of listeners) listener();
|
||||
for (const sectionId of affectedGridIds) {
|
||||
for (const listener of gridListeners.get(sectionId) ?? []) listener();
|
||||
}
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
subscribeGrid: (sectionId, listener) => {
|
||||
const sectionListeners = gridListeners.get(sectionId) ?? new Set<() => void>();
|
||||
sectionListeners.add(listener);
|
||||
gridListeners.set(sectionId, sectionListeners);
|
||||
return () => {
|
||||
sectionListeners.delete(listener);
|
||||
if (sectionListeners.size === 0) gridListeners.delete(sectionId);
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { act, createElement, memo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { DndGridEntryProps } from "../grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
|
||||
|
||||
describe("grid entry memoization", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let renders: number;
|
||||
|
||||
const Probe = memo((props: DndGridEntryProps) => {
|
||||
renders += 1;
|
||||
return createElement("span", { "data-x": props.placement.x });
|
||||
}, areDndGridEntryPropsEqual);
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
renders = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("skips cloned unchanged preview placements and renders changed entries", () => {
|
||||
const props = createProps();
|
||||
|
||||
render(props);
|
||||
expect(renders).toBe(1);
|
||||
|
||||
render({ ...props, placement: { ...props.placement } });
|
||||
expect(renders).toBe(1);
|
||||
|
||||
render({ ...props, placement: { ...props.placement, x: 2 } });
|
||||
expect(renders).toBe(2);
|
||||
});
|
||||
|
||||
test("rerenders only the changed entry on a 100 item preview", () => {
|
||||
const entries = Array.from({ length: 100 }, (_, index) => ({
|
||||
...createProps(),
|
||||
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
|
||||
}));
|
||||
|
||||
renderBoard(entries);
|
||||
expect(renders).toBe(100);
|
||||
|
||||
renderBoard(entries.map((entry) => ({ ...entry, placement: { ...entry.placement } })));
|
||||
expect(renders).toBe(100);
|
||||
|
||||
renderBoard(
|
||||
entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 1 } } : entry,
|
||||
),
|
||||
);
|
||||
expect(renders).toBe(101);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })],
|
||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
||||
[
|
||||
"minimum size",
|
||||
(props: DndGridEntryProps) => ({
|
||||
...props,
|
||||
placement: { ...props.placement, minW: 2 },
|
||||
}),
|
||||
],
|
||||
])("renders when %s changes", (_name, update) => {
|
||||
const props = createProps();
|
||||
render(props);
|
||||
render(update(props));
|
||||
expect(renders).toBe(2);
|
||||
});
|
||||
|
||||
const render = (props: DndGridEntryProps) => {
|
||||
act(() => root.render(createElement(Probe, props)));
|
||||
};
|
||||
|
||||
const renderBoard = (entries: DndGridEntryProps[]) => {
|
||||
act(() =>
|
||||
root.render(
|
||||
createElement(
|
||||
"div",
|
||||
null,
|
||||
entries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const createProps = (): DndGridEntryProps => ({
|
||||
sectionId: "root",
|
||||
placement: { id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
label: "Weather",
|
||||
columnCount: 12,
|
||||
maxRowCount: null,
|
||||
isActive: false,
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createGridInteractionStore } from "../grid-interaction-store";
|
||||
|
||||
describe("grid interaction store", () => {
|
||||
test("notifies only source, current target and previous target grids", () => {
|
||||
const store = createGridInteractionStore<TestInteraction>();
|
||||
const globalListener = vi.fn();
|
||||
const sourceListener = vi.fn();
|
||||
const firstTargetListener = vi.fn();
|
||||
const secondTargetListener = vi.fn();
|
||||
const unrelatedListener = vi.fn();
|
||||
|
||||
store.subscribe(globalListener);
|
||||
store.subscribeGrid("source", sourceListener);
|
||||
store.subscribeGrid("target-a", firstTargetListener);
|
||||
store.subscribeGrid("target-b", secondTargetListener);
|
||||
store.subscribeGrid("unrelated", unrelatedListener);
|
||||
|
||||
store.publish(createInteraction("target-a", 1));
|
||||
expect(sourceListener).toHaveBeenCalledTimes(1);
|
||||
expect(firstTargetListener).toHaveBeenCalledTimes(1);
|
||||
expect(secondTargetListener).not.toHaveBeenCalled();
|
||||
expect(unrelatedListener).not.toHaveBeenCalled();
|
||||
expect(store.getGridSnapshot("target-a")?.previewRevision).toBe(1);
|
||||
|
||||
store.publish(createInteraction("target-b", 2));
|
||||
expect(sourceListener).toHaveBeenCalledTimes(2);
|
||||
expect(firstTargetListener).toHaveBeenCalledTimes(2);
|
||||
expect(secondTargetListener).toHaveBeenCalledTimes(1);
|
||||
expect(unrelatedListener).not.toHaveBeenCalled();
|
||||
expect(store.getGridSnapshot("target-a")).toBeNull();
|
||||
expect(store.getGridSnapshot("target-b")?.previewRevision).toBe(2);
|
||||
|
||||
store.publish(null);
|
||||
expect(sourceListener).toHaveBeenCalledTimes(3);
|
||||
expect(firstTargetListener).toHaveBeenCalledTimes(2);
|
||||
expect(secondTargetListener).toHaveBeenCalledTimes(2);
|
||||
expect(unrelatedListener).not.toHaveBeenCalled();
|
||||
expect(globalListener).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
interface TestInteraction {
|
||||
sourceGridId: "source";
|
||||
targetGridId: string;
|
||||
previewRevision: number;
|
||||
}
|
||||
|
||||
const createInteraction = (targetGridId: string, previewRevision: number): TestInteraction => ({
|
||||
sourceGridId: "source",
|
||||
targetGridId,
|
||||
previewRevision,
|
||||
});
|
||||
Reference in new issue
Block a user