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 {
|
.board-grid-entry {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
transition:
|
}
|
||||||
left 100ms ease,
|
|
||||||
top 100ms ease,
|
.board-grid-entry[data-grid-preview="true"] {
|
||||||
width 100ms ease,
|
width: var(--board-grid-preview-width) !important;
|
||||||
height 100ms ease;
|
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"],
|
.board-grid-entry[data-dnd-active="true"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
|
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
@@ -63,6 +63,8 @@ import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
|||||||
import { getGridDepth } from "./grid-depth";
|
import { getGridDepth } from "./grid-depth";
|
||||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
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 type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||||
|
|
||||||
@@ -772,27 +774,12 @@ export default function GridEditor({
|
|||||||
}: GridEditorProps) {
|
}: GridEditorProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { section, items, innerSections } = useSectionContext();
|
const { section, items, innerSections } = useSectionContext();
|
||||||
const interaction = useBoardGridEditorGridInteraction(sectionId);
|
|
||||||
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const entryElementsRef = useRef(new Map<string, HTMLElement>());
|
||||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||||
const depth = getDepth(sectionId);
|
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(
|
const labelById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
@@ -805,21 +792,7 @@ export default function GridEditor({
|
|||||||
),
|
),
|
||||||
[innerSections, items, t],
|
[innerSections, items, t],
|
||||||
);
|
);
|
||||||
const targetPlacement =
|
const { ref: droppableRef } = useDroppable<GridTargetData>({
|
||||||
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>({
|
|
||||||
id: `board-grid:${sectionId}`,
|
id: `board-grid:${sectionId}`,
|
||||||
type: GRID_TARGET_TYPE,
|
type: GRID_TARGET_TYPE,
|
||||||
data: { kind: GRID_TARGET_TYPE, sectionId },
|
data: { kind: GRID_TARGET_TYPE, sectionId },
|
||||||
@@ -830,7 +803,6 @@ export default function GridEditor({
|
|||||||
// through to an accepting parent grid underneath the pointer.
|
// through to an accepting parent grid underneath the pointer.
|
||||||
accept: (source) => isDropTargetEligible(source, sectionId),
|
accept: (source) => isDropTargetEligible(source, sectionId),
|
||||||
});
|
});
|
||||||
const isIntentTarget = interaction?.mode === "drag" ? interaction.targetGridId === sectionId : isDropTarget;
|
|
||||||
|
|
||||||
const setGridRef = useCallback(
|
const setGridRef = useCallback(
|
||||||
(element: HTMLDivElement | null) => {
|
(element: HTMLDivElement | null) => {
|
||||||
@@ -839,6 +811,10 @@ export default function GridEditor({
|
|||||||
},
|
},
|
||||||
[droppableRef],
|
[droppableRef],
|
||||||
);
|
);
|
||||||
|
const registerEntryElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||||
|
if (element) entryElementsRef.current.set(id, element);
|
||||||
|
else entryElementsRef.current.delete(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = gridRef.current;
|
const element = gridRef.current;
|
||||||
@@ -855,33 +831,21 @@ export default function GridEditor({
|
|||||||
});
|
});
|
||||||
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setGridRef}
|
ref={setGridRef}
|
||||||
className={className}
|
className={className}
|
||||||
data-grid-section-id={sectionId}
|
data-grid-section-id={sectionId}
|
||||||
data-grid-depth={depth}
|
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={
|
style={
|
||||||
{
|
{
|
||||||
width: getLogicalTrackSize(columnCount),
|
width: getLogicalTrackSize(columnCount),
|
||||||
height: getLogicalTrackSize(renderedRowCount),
|
height: getLogicalTrackSize(rowCount),
|
||||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{renderPlacements.map((placement) => {
|
{placements.map((placement) => {
|
||||||
return (
|
return (
|
||||||
<DndGridEntry
|
<DndGridEntry
|
||||||
key={placement.id}
|
key={placement.id}
|
||||||
@@ -890,23 +854,18 @@ export default function GridEditor({
|
|||||||
label={labelById.get(placement.id) ?? placement.id}
|
label={labelById.get(placement.id) ?? placement.id}
|
||||||
columnCount={columnCount}
|
columnCount={columnCount}
|
||||||
maxRowCount={maxRowCount}
|
maxRowCount={maxRowCount}
|
||||||
isActive={interaction?.activeId === placement.id}
|
registerElement={registerEntryElement}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{targetPlacement && (
|
<GridPreviewLayer
|
||||||
<div
|
sectionId={sectionId}
|
||||||
className="board-grid-placeholder"
|
rowCount={rowCount}
|
||||||
style={getLogicalItemStyle(targetPlacement)}
|
maxRowCount={maxRowCount}
|
||||||
data-grid-placeholder-for={interaction?.activeId}
|
placements={placements}
|
||||||
data-grid-placeholder-mode={interaction?.mode}
|
gridRef={gridRef}
|
||||||
data-grid-x={targetPlacement.x}
|
entryElementsRef={entryElementsRef}
|
||||||
data-grid-y={targetPlacement.y}
|
/>
|
||||||
data-grid-w={targetPlacement.w}
|
|
||||||
data-grid-h={targetPlacement.h}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
data-grid-runtime="homarr-dnd-kit-v1"
|
data-grid-runtime="homarr-dnd-kit-v1"
|
||||||
data-editor-section-id={sectionId}
|
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 = ({
|
const DndGridEntryComponent = ({
|
||||||
sectionId,
|
sectionId,
|
||||||
placement,
|
placement,
|
||||||
label,
|
label,
|
||||||
columnCount,
|
columnCount,
|
||||||
maxRowCount,
|
maxRowCount,
|
||||||
isActive,
|
registerElement,
|
||||||
}: DndGridEntryProps) => {
|
}: DndGridEntryProps) => {
|
||||||
const { acquireContainer } = useBoardGridPortalHost();
|
const { acquireContainer } = useBoardGridPortalHost();
|
||||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||||
const [continuousResize, setContinuousResize] = useState<{
|
|
||||||
placement: SectionGridPlacement;
|
|
||||||
valid: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const mountRef = useRef<HTMLDivElement>(null);
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
const shellRef = 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>(
|
const sourceData = useMemo<GridEntryData>(
|
||||||
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
|
||||||
[label, placement, sectionId],
|
[label, placement, sectionId],
|
||||||
@@ -973,11 +1035,14 @@ const DndGridEntryComponent = ({
|
|||||||
const setShellRef = useCallback(
|
const setShellRef = useCallback(
|
||||||
(element: HTMLDivElement | null) => {
|
(element: HTMLDivElement | null) => {
|
||||||
shellRef.current = element;
|
shellRef.current = element;
|
||||||
|
registerElement(placement.id, element);
|
||||||
draggableRef(element);
|
draggableRef(element);
|
||||||
},
|
},
|
||||||
[draggableRef],
|
[draggableRef, placement.id, registerElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const mount = mountRef.current;
|
const mount = mountRef.current;
|
||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
@@ -1020,21 +1085,11 @@ const DndGridEntryComponent = ({
|
|||||||
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||||
}, [placement.type]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setShellRef}
|
ref={setShellRef}
|
||||||
className="board-grid-entry"
|
className="board-grid-entry"
|
||||||
style={style}
|
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||||
data-grid-item-id={placement.id}
|
data-grid-item-id={placement.id}
|
||||||
data-grid-item-type={placement.type}
|
data-grid-item-type={placement.type}
|
||||||
data-grid-x={placement.x}
|
data-grid-x={placement.x}
|
||||||
@@ -1042,20 +1097,10 @@ const DndGridEntryComponent = ({
|
|||||||
data-grid-w={placement.w}
|
data-grid-w={placement.w}
|
||||||
data-grid-h={placement.h}
|
data-grid-h={placement.h}
|
||||||
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
|
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-drag-source={isDragging ? "true" : undefined}
|
||||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<div ref={mountRef} className="board-grid-content-mount" />
|
<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
|
<GridResizeHandles
|
||||||
sectionId={sectionId}
|
sectionId={sectionId}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
@@ -1067,7 +1112,7 @@ const DndGridEntryComponent = ({
|
|||||||
previewResize={previewResize}
|
previewResize={previewResize}
|
||||||
completeResize={completeResize}
|
completeResize={completeResize}
|
||||||
cancelResize={cancelResize}
|
cancelResize={cancelResize}
|
||||||
setContinuousResize={setContinuousResize}
|
setContinuousResize={resizeOutlineController.schedule}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1280,6 +1325,11 @@ const getAncestorCssZoom = (element: Element): number => {
|
|||||||
const arePointerCoordinatesEqual = (first: { x: number; y: number } | null, second: { x: number; y: number } | null) =>
|
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;
|
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 = (
|
const getPointerTargetGrid = (
|
||||||
grids: Iterable<RegisteredGrid>,
|
grids: Iterable<RegisteredGrid>,
|
||||||
source: AbstractDraggable,
|
source: AbstractDraggable,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
|
|||||||
label: string;
|
label: string;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
maxRowCount: number | null;
|
maxRowCount: number | null;
|
||||||
isActive: boolean;
|
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
|
||||||
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
|
|||||||
previous.label === next.label &&
|
previous.label === next.label &&
|
||||||
previous.columnCount === next.columnCount &&
|
previous.columnCount === next.columnCount &&
|
||||||
previous.maxRowCount === next.maxRowCount &&
|
previous.maxRowCount === next.maxRowCount &&
|
||||||
previous.isActive === next.isActive &&
|
previous.registerElement === next.registerElement &&
|
||||||
previous.placement.id === next.placement.id &&
|
previous.placement.id === next.placement.id &&
|
||||||
previous.placement.type === next.placement.type &&
|
previous.placement.type === next.placement.type &&
|
||||||
previous.placement.x === next.placement.x &&
|
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 type { DndGridEntryProps } from "../grid-entry-memo";
|
||||||
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
|
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
|
||||||
|
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
|
||||||
|
|
||||||
describe("grid entry memoization", () => {
|
describe("grid entry memoization", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
let renders: number;
|
let renders = 0;
|
||||||
|
let widgetRenders = 0;
|
||||||
|
|
||||||
const Probe = memo((props: DndGridEntryProps) => {
|
const Probe = memo((props: DndGridEntryProps) => {
|
||||||
renders += 1;
|
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);
|
}, areDndGridEntryPropsEqual);
|
||||||
|
const WidgetProbe = () => {
|
||||||
|
widgetRenders += 1;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
renders = 0;
|
renders = 0;
|
||||||
|
widgetRenders = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -61,10 +76,52 @@ describe("grid entry memoization", () => {
|
|||||||
expect(renders).toBe(101);
|
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([
|
test.each([
|
||||||
["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })],
|
|
||||||
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
|
||||||
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
|
||||||
|
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
|
||||||
[
|
[
|
||||||
"minimum size",
|
"minimum size",
|
||||||
(props: DndGridEntryProps) => ({
|
(props: DndGridEntryProps) => ({
|
||||||
@@ -102,5 +159,5 @@ const createProps = (): DndGridEntryProps => ({
|
|||||||
label: "Weather",
|
label: "Weather",
|
||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
maxRowCount: null,
|
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