fix: improve board grid interactions
This commit is contained in:
1 parent
915af9ae96
commit
2f28f45ff1
10 files changed
+189
-16
No files matched your search
@@ -10,3 +10,7 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
/**
|
||||
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||
* two slots and leave every other item where it started.
|
||||
*/
|
||||
export const getDropSwapPlacements = (
|
||||
initial: readonly SectionGridPlacement[],
|
||||
current: readonly SectionGridPlacement[],
|
||||
movedItemId: string,
|
||||
): SectionGridPlacement[] | null => {
|
||||
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||
if (!movedInitially || !movedCurrently) return null;
|
||||
if (
|
||||
movedInitially.x === movedCurrently.x &&
|
||||
movedInitially.y === movedCurrently.y &&
|
||||
movedInitially.w === movedCurrently.w &&
|
||||
movedInitially.h === movedCurrently.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaced = initial.find(
|
||||
(placement) =>
|
||||
placement.id !== movedItemId &&
|
||||
placement.x === movedCurrently.x &&
|
||||
placement.y === movedCurrently.y &&
|
||||
placement.w === movedCurrently.w &&
|
||||
placement.h === movedCurrently.h,
|
||||
);
|
||||
if (!displaced) return null;
|
||||
|
||||
return initial.map((placement) => {
|
||||
if (placement.id === movedItemId) {
|
||||
return {
|
||||
...placement,
|
||||
x: displaced.x,
|
||||
y: displaced.y,
|
||||
};
|
||||
}
|
||||
if (placement.id === displaced.id) {
|
||||
return {
|
||||
...placement,
|
||||
x: movedInitially.x,
|
||||
y: movedInitially.y,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
};
|
||||
@@ -23,9 +23,10 @@
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-size: 12px 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-size: 16px 16px;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { getDropSwapPlacements } from "./grid-drop-swap";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -54,6 +55,11 @@ export default function GridEditor({
|
||||
const gridRef = useRef<GridStack | null>(null);
|
||||
const applyingControlledLayoutRef = useRef(false);
|
||||
const placementsRef = useRef(placements);
|
||||
const dragSnapshotRef = useRef<{
|
||||
grid: GridStack;
|
||||
itemId: string;
|
||||
placements: SectionGridPlacement[];
|
||||
} | null>(null);
|
||||
const entriesRef = useRef([...items, ...innerSections]);
|
||||
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
|
||||
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
|
||||
@@ -205,12 +211,43 @@ export default function GridEditor({
|
||||
wrapper.classList.add("grid-stack-editing");
|
||||
|
||||
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
|
||||
eventHandlersRef.current.commitGrid(element.gridstackNode?.grid ?? grid);
|
||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||
decorateGrid(element.gridstackNode?.grid ?? grid, placementsRef.current);
|
||||
restoreContentHeight(grid);
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const dragSnapshot = dragSnapshotRef.current;
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (dragSnapshot?.grid === changedGrid) {
|
||||
const swapped = getDropSwapPlacements(
|
||||
dragSnapshot.placements,
|
||||
getGridPlacements(changedGrid),
|
||||
dragSnapshot.itemId,
|
||||
);
|
||||
if (swapped) {
|
||||
applyingControlledLayoutRef.current = true;
|
||||
try {
|
||||
changedGrid.load(swapped.map(toGridStackWidget), shellCallback);
|
||||
} finally {
|
||||
applyingControlledLayoutRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventHandlersRef.current.commitGrid(changedGrid);
|
||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||
decorateGrid(changedGrid, placementsRef.current);
|
||||
restoreContentHeight(grid);
|
||||
});
|
||||
};
|
||||
const handleDragStart = () => {
|
||||
const handleDragStart = (_event: Event, element: GridItemHTMLElement) => {
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const itemId = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
|
||||
if (itemId) {
|
||||
dragSnapshotRef.current = {
|
||||
grid: changedGrid,
|
||||
itemId: String(itemId),
|
||||
placements: getGridPlacements(changedGrid),
|
||||
};
|
||||
}
|
||||
|
||||
const currentDimensions = dimensionsRef.current;
|
||||
if (currentDimensions.fixedRowCount) return;
|
||||
|
||||
@@ -224,6 +261,7 @@ export default function GridEditor({
|
||||
previousNode: GridStackNode | undefined,
|
||||
newNode: GridStackNode | undefined,
|
||||
) => {
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (applyingControlledLayoutRef.current) return;
|
||||
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
|
||||
@@ -358,7 +396,7 @@ const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["
|
||||
element.setAttribute("data-grid-y", String(node.y ?? 0));
|
||||
element.setAttribute("data-grid-w", String(node.w ?? 1));
|
||||
element.setAttribute("data-grid-h", String(node.h ?? 1));
|
||||
decorateGridResizeHandles(element);
|
||||
decorateGridResizeHandles(element, { minW: node.minW, minH: node.minH });
|
||||
};
|
||||
|
||||
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
|
||||
|
||||
@@ -129,7 +129,10 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
|
||||
if (!itemElement) return;
|
||||
|
||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement, {
|
||||
minW: itemElement.gridstackNode?.minW,
|
||||
minH: itemElement.gridstackNode?.minH,
|
||||
});
|
||||
}, [container, ownerSectionId]);
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const decorateGridResizeHandles = (element: Element) => {
|
||||
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface GridResizeMinimum {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
|
||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||
const direction = Array.from(handle.classList).find((className) =>
|
||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||
|
||||
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||
const initial = [first, second, third];
|
||||
|
||||
describe("grid drop swapping", () => {
|
||||
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||
|
||||
expect(getDropSwapPlacements(initial, current, "first")).toEqual([{ ...first, x: 1 }, { ...second, x: 0 }, third]);
|
||||
});
|
||||
|
||||
test("keeps push behavior for different sizes or partial placement", () => {
|
||||
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||
|
||||
expect(getDropSwapPlacements(initial, differentSize, "first")).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, partialPlacement, "third")).toBeNull();
|
||||
});
|
||||
|
||||
test("does nothing when the item did not move", () => {
|
||||
expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||
|
||||
describe("grid resize handle decoration", () => {
|
||||
@@ -10,7 +11,7 @@ describe("grid resize handle decoration", () => {
|
||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||
`;
|
||||
|
||||
decorateGridResizeHandles(item);
|
||||
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||
|
||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||
@@ -20,5 +21,7 @@ describe("grid resize handle decoration", () => {
|
||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user