diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index f5f389590..d939d19aa 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -219,8 +219,9 @@ Press and hold the handle, move the pointer, and release it when the tile has th ![](./img/resize-item.gif) -To move a tile in edit mode, press and hold anywhere on the tile except one of its buttons or controls, move it to the desired position, and release it. -The tile snaps to the grid and neighboring tiles move out of the way automatically. +To move a tile in edit mode, press and hold its content, move it to the desired position, and release it. +Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions. +The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically. ![](./img/move-item.gif) diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index f0f6ce4bc..0c95d0b81 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -10,3 +10,7 @@ background-color: var(--background-color) !important; border-color: var(--border-color) !important; } + +.editModeInertContent { + display: contents; +} diff --git a/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts new file mode 100644 index 000000000..6f0eee0cb --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-drop-swap.ts @@ -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; + }); +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index cc21f2c16..bd51ec827 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -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) { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index d9dd92687..fe6f21f2f 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -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(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 => { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx index fd5e4d4e6..de2c872f2 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx @@ -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; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts b/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts index 111b2d0d2..a5ee0efac 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts +++ b/apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts @@ -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(".ui-resizable-handle")) { const direction = Array.from(handle.classList).find((className) => /^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className), diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts new file mode 100644 index 000000000..edac286ff --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-drop-swap.spec.ts @@ -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(); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts index 3f93e7b7b..6efd9cdca 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts @@ -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", () => {
`; - decorateGridResizeHandles(item); + decorateGridResizeHandles(item, { minW: 2, minH: 3 }); const east = item.querySelector(".ui-resizable-e"); const southeast = item.querySelector(".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`); }); }); diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 2bc8483c9..f836ac229 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -208,6 +208,8 @@ describe("Board grid", () => { await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group"); await expectTargetSizeAsync(getEditorEntry(firstItem)); await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]')); + await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44); + await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", ""); await page.getByRole("button", { name: "Add board content" }).click(); await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible(); @@ -220,7 +222,11 @@ describe("Board grid", () => { const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail); const canvasScale = await readCanvasScaleAsync(canvas); - await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 4, 0); + await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0); + await expect(firstItem).toHaveAttribute("data-grid-x", "1"); + await expect(secondItem).toHaveAttribute("data-grid-x", "0"); + + await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe( false, @@ -279,7 +285,23 @@ describe("Board grid", () => { await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false"); const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]'); - await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale); + await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44); + await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale); + await expect(dynamicSection).toHaveAttribute("data-grid-h", "1"); + + const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection); + const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle); + const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2; + const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2; + await page.mouse.move(resizeStartX, resizeStartY); + await page.mouse.down(); + await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 }); + const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection); + expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1); + await page.mouse.up(); + await expect(dynamicSection).toHaveAttribute("data-grid-h", "1"); + + await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2); await expect(dynamicSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe( @@ -916,6 +938,15 @@ const expectTargetSizeAsync = async (locator: Locator) => { expect(box.height).toBeGreaterThanOrEqual(24); }; +const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => { + const dimensions = await locator.evaluate((element) => ({ + width: (element as HTMLElement).offsetWidth, + height: (element as HTMLElement).offsetHeight, + })); + expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize); + expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize); +}; + const readGridLayoutAsync = async (locator: Locator) => { const [x, y, width, height] = await Promise.all([ locator.getAttribute("data-grid-x"),