perf: isolate board grid previews
This commit is contained in:
8 files changed
+517
-100
No files matched your search
@@ -8,11 +8,13 @@
|
||||
.board-grid-entry {
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"] {
|
||||
width: var(--board-grid-preview-width) !important;
|
||||
height: var(--board-grid-preview-height) !important;
|
||||
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -63,6 +63,8 @@ import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
|
||||
import { createGridResizeOutlineController } from "./grid-resize-outline";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
@@ -772,27 +774,12 @@ export default function GridEditor({
|
||||
}: GridEditorProps) {
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const interaction = useBoardGridEditorGridInteraction(sectionId);
|
||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const entryElementsRef = useRef(new Map<string, HTMLElement>());
|
||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
const depth = getDepth(sectionId);
|
||||
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
|
||||
const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]);
|
||||
const renderPlacements = useMemo(() => {
|
||||
if (!previewGrid || !interaction) return placements;
|
||||
|
||||
if (interaction.sourceGridId === sectionId) {
|
||||
if (interaction.mode === "resize") return previewGrid.placements;
|
||||
const original = controlledById.get(interaction.activeId);
|
||||
if (!original) return previewGrid.placements;
|
||||
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
|
||||
? previewGrid.placements.map((placement) => (placement.id === interaction.activeId ? original : placement))
|
||||
: [...previewGrid.placements, original];
|
||||
}
|
||||
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
|
||||
}, [controlledById, interaction, placements, previewGrid, sectionId]);
|
||||
const labelById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -805,21 +792,7 @@ export default function GridEditor({
|
||||
),
|
||||
[innerSections, items, t],
|
||||
);
|
||||
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 participatesInInteraction =
|
||||
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
|
||||
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
|
||||
const renderedRowCount =
|
||||
maxRowCount === null && participatesInInteraction
|
||||
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
|
||||
: rowCount;
|
||||
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
|
||||
const { ref: droppableRef } = useDroppable<GridTargetData>({
|
||||
id: `board-grid:${sectionId}`,
|
||||
type: GRID_TARGET_TYPE,
|
||||
data: { kind: GRID_TARGET_TYPE, sectionId },
|
||||
@@ -830,7 +803,6 @@ export default function GridEditor({
|
||||
// through to an accepting parent grid underneath the pointer.
|
||||
accept: (source) => isDropTargetEligible(source, sectionId),
|
||||
});
|
||||
const isIntentTarget = interaction?.mode === "drag" ? interaction.targetGridId === sectionId : isDropTarget;
|
||||
|
||||
const setGridRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
@@ -839,6 +811,10 @@ 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);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = gridRef.current;
|
||||
@@ -855,33 +831,21 @@ export default function GridEditor({
|
||||
});
|
||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = gridRef.current?.parentElement;
|
||||
if (!viewport?.hasAttribute("data-section-id")) return;
|
||||
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
|
||||
return () => {
|
||||
viewport.style.removeProperty("--board-grid-drag-height");
|
||||
};
|
||||
}, [renderedRowCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setGridRef}
|
||||
className={className}
|
||||
data-grid-section-id={sectionId}
|
||||
data-grid-depth={depth}
|
||||
data-dnd-drop-target={isIntentTarget ? "true" : "false"}
|
||||
data-dnd-drop-valid={isIntentTarget ? String(targetPlacement !== null) : undefined}
|
||||
data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined}
|
||||
style={
|
||||
{
|
||||
width: getLogicalTrackSize(columnCount),
|
||||
height: getLogicalTrackSize(renderedRowCount),
|
||||
height: getLogicalTrackSize(rowCount),
|
||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{renderPlacements.map((placement) => {
|
||||
{placements.map((placement) => {
|
||||
return (
|
||||
<DndGridEntry
|
||||
key={placement.id}
|
||||
@@ -890,23 +854,18 @@ export default function GridEditor({
|
||||
label={labelById.get(placement.id) ?? placement.id}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
isActive={interaction?.activeId === placement.id}
|
||||
registerElement={registerEntryElement}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{targetPlacement && (
|
||||
<div
|
||||
className="board-grid-placeholder"
|
||||
style={getLogicalItemStyle(targetPlacement)}
|
||||
data-grid-placeholder-for={interaction?.activeId}
|
||||
data-grid-placeholder-mode={interaction?.mode}
|
||||
data-grid-x={targetPlacement.x}
|
||||
data-grid-y={targetPlacement.y}
|
||||
data-grid-w={targetPlacement.w}
|
||||
data-grid-h={targetPlacement.h}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<GridPreviewLayer
|
||||
sectionId={sectionId}
|
||||
rowCount={rowCount}
|
||||
maxRowCount={maxRowCount}
|
||||
placements={placements}
|
||||
gridRef={gridRef}
|
||||
entryElementsRef={entryElementsRef}
|
||||
/>
|
||||
<span
|
||||
data-grid-runtime="homarr-dnd-kit-v1"
|
||||
data-editor-section-id={sectionId}
|
||||
@@ -917,23 +876,126 @@ export default function GridEditor({
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPreviewLayerProps {
|
||||
sectionId: string;
|
||||
rowCount: number;
|
||||
maxRowCount: number | null;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
entryElementsRef: RefObject<Map<string, HTMLElement>>;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div
|
||||
className="board-grid-placeholder"
|
||||
style={getLogicalItemStyle(targetPlacement)}
|
||||
data-grid-placeholder-for={interaction?.activeId}
|
||||
data-grid-placeholder-mode={interaction?.mode}
|
||||
data-grid-x={targetPlacement.x}
|
||||
data-grid-y={targetPlacement.y}
|
||||
data-grid-w={targetPlacement.w}
|
||||
data-grid-h={targetPlacement.h}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const DndGridEntryComponent = ({
|
||||
sectionId,
|
||||
placement,
|
||||
label,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
isActive,
|
||||
registerElement,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer } = useBoardGridPortalHost();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||
const [continuousResize, setContinuousResize] = useState<{
|
||||
placement: SectionGridPlacement;
|
||||
valid: boolean;
|
||||
} | null>(null);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
placementRef.current = placement;
|
||||
const resizeOutlineController = useMemo(
|
||||
() =>
|
||||
createGridResizeOutlineController(
|
||||
() => shellRef.current,
|
||||
() => placementRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
const sourceData = useMemo<GridEntryData>(
|
||||
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
||||
[label, placement, sectionId],
|
||||
@@ -973,11 +1035,14 @@ const DndGridEntryComponent = ({
|
||||
const setShellRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
shellRef.current = element;
|
||||
registerElement(placement.id, element);
|
||||
draggableRef(element);
|
||||
},
|
||||
[draggableRef],
|
||||
[draggableRef, placement.id, registerElement],
|
||||
);
|
||||
|
||||
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
@@ -1020,21 +1085,11 @@ const DndGridEntryComponent = ({
|
||||
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
}, [placement.type]);
|
||||
|
||||
const style = getLogicalItemStyle(placement) as CSSProperties;
|
||||
const continuousResizeStyle =
|
||||
continuousResize && isActive
|
||||
? getLogicalItemStyle({
|
||||
...continuousResize.placement,
|
||||
x: continuousResize.placement.x - placement.x,
|
||||
y: continuousResize.placement.y - placement.y,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setShellRef}
|
||||
className="board-grid-entry"
|
||||
style={style}
|
||||
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||
data-grid-item-id={placement.id}
|
||||
data-grid-item-type={placement.type}
|
||||
data-grid-x={placement.x}
|
||||
@@ -1042,20 +1097,10 @@ const DndGridEntryComponent = ({
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
|
||||
data-dnd-active={isActive ? "true" : undefined}
|
||||
data-dnd-drag-source={isDragging ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
{continuousResizeStyle && (
|
||||
<div
|
||||
className="board-grid-resize-outline"
|
||||
style={continuousResizeStyle}
|
||||
data-grid-resize-outline
|
||||
data-grid-resize-valid={String(continuousResize?.valid)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<GridResizeHandles
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
@@ -1067,7 +1112,7 @@ const DndGridEntryComponent = ({
|
||||
previewResize={previewResize}
|
||||
completeResize={completeResize}
|
||||
cancelResize={cancelResize}
|
||||
setContinuousResize={setContinuousResize}
|
||||
setContinuousResize={resizeOutlineController.schedule}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1280,6 +1325,11 @@ const getAncestorCssZoom = (element: Element): number => {
|
||||
const arePointerCoordinatesEqual = (first: { x: number; y: number } | null, second: { x: number; y: number } | null) =>
|
||||
first !== null && second !== null && Math.abs(first.x - second.x) <= 0.5 && Math.abs(first.y - second.y) <= 0.5;
|
||||
|
||||
const setOptionalAttribute = (element: HTMLElement, name: string, value: string | null) => {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
};
|
||||
|
||||
const getPointerTargetGrid = (
|
||||
grids: Iterable<RegisteredGrid>,
|
||||
source: AbstractDraggable,
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
isActive: boolean;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
|
||||
previous.label === next.label &&
|
||||
previous.columnCount === next.columnCount &&
|
||||
previous.maxRowCount === next.maxRowCount &&
|
||||
previous.isActive === next.isActive &&
|
||||
previous.registerElement === next.registerElement &&
|
||||
previous.placement.id === next.placement.id &&
|
||||
previous.placement.type === next.placement.type &&
|
||||
previous.placement.x === next.placement.x &&
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
export interface GridPreviewDomState {
|
||||
activeId: string | null;
|
||||
changedIds: Set<string>;
|
||||
}
|
||||
|
||||
interface SyncGridPreviewGeometryInput<TPlacement extends GridPlacement> {
|
||||
elements: ReadonlyMap<string, HTMLElement>;
|
||||
placements: readonly TPlacement[];
|
||||
previewPlacements: readonly TPlacement[] | null;
|
||||
activeId: string | null;
|
||||
mode: "drag" | "resize" | null;
|
||||
previous: GridPreviewDomState;
|
||||
}
|
||||
|
||||
export const createGridPreviewDomState = (): GridPreviewDomState => ({
|
||||
activeId: null,
|
||||
changedIds: new Set(),
|
||||
});
|
||||
|
||||
export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
|
||||
elements,
|
||||
placements,
|
||||
previewPlacements,
|
||||
activeId,
|
||||
mode,
|
||||
previous,
|
||||
}: SyncGridPreviewGeometryInput<TPlacement>): GridPreviewDomState => {
|
||||
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
|
||||
const changedIds = new Set<string>();
|
||||
|
||||
if (previewPlacements && mode) {
|
||||
for (const preview of previewPlacements) {
|
||||
const controlled = controlledById.get(preview.id);
|
||||
const element = elements.get(preview.id);
|
||||
if (!controlled || !element || (mode === "drag" && preview.id === activeId)) continue;
|
||||
if (arePlacementsEqual(controlled, preview)) continue;
|
||||
|
||||
applyPreviewGeometry(element, controlled, preview);
|
||||
changedIds.add(preview.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of previous.changedIds) {
|
||||
if (changedIds.has(id)) continue;
|
||||
const element = elements.get(id);
|
||||
const controlled = controlledById.get(id);
|
||||
if (element && controlled) clearPreviewGeometry(element, controlled);
|
||||
}
|
||||
|
||||
if (previous.activeId && previous.activeId !== activeId) {
|
||||
elements.get(previous.activeId)?.removeAttribute("data-dnd-active");
|
||||
}
|
||||
if (activeId) elements.get(activeId)?.setAttribute("data-dnd-active", "true");
|
||||
|
||||
return { activeId, changedIds };
|
||||
};
|
||||
|
||||
export const clearGridPreviewGeometry = <TPlacement extends GridPlacement>(
|
||||
elements: ReadonlyMap<string, HTMLElement>,
|
||||
placements: readonly TPlacement[],
|
||||
state: GridPreviewDomState,
|
||||
) => {
|
||||
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
|
||||
for (const id of state.changedIds) {
|
||||
const element = elements.get(id);
|
||||
const controlled = controlledById.get(id);
|
||||
if (element && controlled) clearPreviewGeometry(element, controlled);
|
||||
}
|
||||
if (state.activeId) elements.get(state.activeId)?.removeAttribute("data-dnd-active");
|
||||
};
|
||||
|
||||
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
|
||||
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
|
||||
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
|
||||
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
|
||||
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
|
||||
element.setAttribute("data-grid-preview", "true");
|
||||
setPlacementAttributes(element, preview);
|
||||
};
|
||||
|
||||
const clearPreviewGeometry = (element: HTMLElement, controlled: GridPlacement) => {
|
||||
element.style.removeProperty("--board-grid-preview-x");
|
||||
element.style.removeProperty("--board-grid-preview-y");
|
||||
element.style.removeProperty("--board-grid-preview-width");
|
||||
element.style.removeProperty("--board-grid-preview-height");
|
||||
element.removeAttribute("data-grid-preview");
|
||||
setPlacementAttributes(element, controlled);
|
||||
};
|
||||
|
||||
const setPlacementAttributes = (element: HTMLElement, placement: GridPlacement) => {
|
||||
element.dataset.gridX = String(placement.x);
|
||||
element.dataset.gridY = String(placement.y);
|
||||
element.dataset.gridW = String(placement.w);
|
||||
element.dataset.gridH = String(placement.h);
|
||||
};
|
||||
|
||||
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
interface ResizeOutlinePreview<TPlacement extends GridPlacement> {
|
||||
placement: TPlacement;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export const createGridResizeOutlineController = <TPlacement extends GridPlacement>(
|
||||
getShell: () => HTMLElement | null,
|
||||
getControlledPlacement: () => TPlacement,
|
||||
) => {
|
||||
let frame = 0;
|
||||
let pending: ResizeOutlinePreview<TPlacement> | null = null;
|
||||
|
||||
const remove = () => {
|
||||
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
frame = 0;
|
||||
const shell = getShell();
|
||||
const preview = pending;
|
||||
if (!shell || !preview) return;
|
||||
|
||||
const controlled = getControlledPlacement();
|
||||
const visualX = toFiniteNumber(shell.dataset.gridX, controlled.x);
|
||||
const visualY = toFiniteNumber(shell.dataset.gridY, controlled.y);
|
||||
const outline =
|
||||
shell.querySelector<HTMLElement>(":scope > [data-grid-resize-outline]") ?? createResizeOutline(shell);
|
||||
outline.style.position = "absolute";
|
||||
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
|
||||
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
|
||||
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
|
||||
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
|
||||
outline.dataset.gridResizeValid = String(preview.valid);
|
||||
};
|
||||
|
||||
return {
|
||||
schedule(preview: ResizeOutlinePreview<TPlacement> | null) {
|
||||
pending = preview;
|
||||
if (!preview) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
if (frame !== 0) return;
|
||||
frame = window.requestAnimationFrame(flush);
|
||||
},
|
||||
destroy() {
|
||||
pending = null;
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: string | undefined, fallback: number) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
};
|
||||
|
||||
const createResizeOutline = (shell: HTMLElement) => {
|
||||
const outline = document.createElement("div");
|
||||
outline.className = "board-grid-resize-outline";
|
||||
outline.dataset.gridResizeOutline = "";
|
||||
outline.setAttribute("aria-hidden", "true");
|
||||
shell.append(outline);
|
||||
return outline;
|
||||
};
|
||||
@@ -5,22 +5,37 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { DndGridEntryProps } from "../grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
|
||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||
|
||||
describe("grid entry memoization", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let renders: number;
|
||||
let renders = 0;
|
||||
let widgetRenders = 0;
|
||||
|
||||
const Probe = memo((props: DndGridEntryProps) => {
|
||||
renders += 1;
|
||||
return createElement("span", { "data-x": props.placement.x });
|
||||
return createElement(
|
||||
"span",
|
||||
{
|
||||
"data-grid-id": props.placement.id,
|
||||
"data-x": props.placement.x,
|
||||
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
|
||||
},
|
||||
createElement(WidgetProbe),
|
||||
);
|
||||
}, areDndGridEntryPropsEqual);
|
||||
const WidgetProbe = () => {
|
||||
widgetRenders += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
renders = 0;
|
||||
widgetRenders = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -61,10 +76,52 @@ describe("grid entry memoization", () => {
|
||||
expect(renders).toBe(101);
|
||||
});
|
||||
|
||||
test("keeps 100 entry and widget renders frozen while preview geometry changes", () => {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const registerElement = (id: string, element: HTMLElement | null) => {
|
||||
if (element) elements.set(id, element);
|
||||
else elements.delete(id);
|
||||
};
|
||||
const entries = Array.from({ length: 100 }, (_, index) => ({
|
||||
...createProps(),
|
||||
registerElement,
|
||||
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
|
||||
}));
|
||||
renderBoard(entries);
|
||||
expect(renders).toBe(100);
|
||||
expect(widgetRenders).toBe(100);
|
||||
|
||||
let previewState = createGridPreviewDomState();
|
||||
for (let revision = 1; revision <= 20; revision += 1) {
|
||||
previewState = syncGridPreviewGeometry({
|
||||
elements,
|
||||
placements: entries.map((entry) => entry.placement),
|
||||
previewPlacements: entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
|
||||
),
|
||||
activeId: "item-0",
|
||||
mode: "drag",
|
||||
previous: previewState,
|
||||
});
|
||||
}
|
||||
|
||||
expect(renders).toBe(100);
|
||||
expect(widgetRenders).toBe(100);
|
||||
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
|
||||
|
||||
renderBoard(
|
||||
entries.map((entry, index) =>
|
||||
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
|
||||
),
|
||||
);
|
||||
expect(renders).toBe(101);
|
||||
expect(widgetRenders).toBe(101);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })],
|
||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
||||
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
|
||||
[
|
||||
"minimum size",
|
||||
(props: DndGridEntryProps) => ({
|
||||
@@ -102,5 +159,5 @@ const createProps = (): DndGridEntryProps => ({
|
||||
label: "Weather",
|
||||
columnCount: 12,
|
||||
maxRowCount: null,
|
||||
isActive: false,
|
||||
registerElement: () => undefined,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||
|
||||
describe("grid preview geometry", () => {
|
||||
let elements: Map<string, HTMLElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
elements = new Map([
|
||||
["active", createEntry("active", 0, 0, 2, 2)],
|
||||
["displaced", createEntry("displaced", 2, 0, 1, 1)],
|
||||
]);
|
||||
});
|
||||
|
||||
test("moves displaced entries without moving the active drag source", () => {
|
||||
const placements = [placement("active", 0, 0, 2, 2), placement("displaced", 2, 0, 1, 1)];
|
||||
const state = syncGridPreviewGeometry({
|
||||
elements,
|
||||
placements,
|
||||
previewPlacements: [placement("active", 2, 0, 2, 2), placement("displaced", 0, 2, 1, 1)],
|
||||
activeId: "active",
|
||||
mode: "drag",
|
||||
previous: createGridPreviewDomState(),
|
||||
});
|
||||
|
||||
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
|
||||
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
|
||||
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
|
||||
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
|
||||
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
|
||||
|
||||
syncGridPreviewGeometry({
|
||||
elements,
|
||||
placements,
|
||||
previewPlacements: null,
|
||||
activeId: null,
|
||||
mode: null,
|
||||
previous: state,
|
||||
});
|
||||
|
||||
expect(elements.get("active")?.hasAttribute("data-dnd-active")).toBe(false);
|
||||
expect(elements.get("displaced")?.hasAttribute("data-grid-preview")).toBe(false);
|
||||
expect(elements.get("displaced")?.dataset.gridX).toBe("2");
|
||||
expect(elements.get("displaced")?.dataset.gridY).toBe("0");
|
||||
});
|
||||
|
||||
test("applies snapped size and position to the active resize shell", () => {
|
||||
syncGridPreviewGeometry({
|
||||
elements,
|
||||
placements: [placement("active", 0, 0, 2, 2)],
|
||||
previewPlacements: [placement("active", 1, 1, 3, 4)],
|
||||
activeId: "active",
|
||||
mode: "resize",
|
||||
previous: createGridPreviewDomState(),
|
||||
});
|
||||
|
||||
const active = elements.get("active");
|
||||
expect(active?.getAttribute("data-grid-preview")).toBe("true");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
|
||||
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
|
||||
expect(active?.dataset.gridW).toBe("3");
|
||||
expect(active?.dataset.gridH).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
const placement = (id: string, x: number, y: number, w: number, h: number) => ({
|
||||
id,
|
||||
type: "item" as const,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
});
|
||||
|
||||
const createEntry = (id: string, x: number, y: number, w: number, h: number) => {
|
||||
const element = document.createElement("div");
|
||||
element.dataset.gridItemId = id;
|
||||
element.dataset.gridX = String(x);
|
||||
element.dataset.gridY = String(y);
|
||||
element.dataset.gridW = String(w);
|
||||
element.dataset.gridH = String(h);
|
||||
return element;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createGridResizeOutlineController } from "../grid-resize-outline";
|
||||
|
||||
describe("grid resize outline", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
test("coalesces pointer updates and removes the outline synchronously", () => {
|
||||
const frame = { callback: undefined as FrameRequestCallback | undefined };
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
frame.callback = callback;
|
||||
return 7;
|
||||
});
|
||||
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||||
const shell = document.createElement("div");
|
||||
const controlled = placement(1, 2, 2, 2);
|
||||
const controller = createGridResizeOutlineController(
|
||||
() => shell,
|
||||
() => controlled,
|
||||
);
|
||||
|
||||
controller.schedule({ placement: placement(1, 2, 2, 2), valid: true });
|
||||
controller.schedule({ placement: placement(1, 2, 1, 2), valid: false });
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1);
|
||||
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
|
||||
|
||||
frame.callback?.(0);
|
||||
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
|
||||
expect(outline?.style.left).toBe("0px");
|
||||
expect(outline?.style.top).toBe("0px");
|
||||
expect(outline?.style.width).toBe("200px");
|
||||
expect(outline?.style.height).toBe("424px");
|
||||
expect(outline?.dataset.gridResizeValid).toBe("false");
|
||||
|
||||
controller.schedule(null);
|
||||
expect(cancelFrame).toHaveBeenCalledWith(0);
|
||||
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
|
||||
controller.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
const placement = (x: number, y: number, w: number, h: number) => ({
|
||||
id: "item",
|
||||
type: "item" as const,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
});
|
||||
Reference in new issue
Block a user