fix: refine dashboard interactions and widget options (#6597)
* fix: refine dashboard item interactions * fix board interactions and widget options * feat: improve bookmark selection * fix: address dashboard review feedback
This commit is contained in:
1 parent
b491d45fdc
commit
4505ce96c9
37 files changed
+824
-503
No files matched your search
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
@@ -36,7 +37,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
@@ -95,16 +95,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
);
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||
<Menu.Target>
|
||||
@@ -123,7 +113,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
@@ -149,10 +138,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tItem("action.remove")}
|
||||
confirmationLabel={tItem("remove.message")}
|
||||
onConfirm={() => removeItem({ itemId: item.id })}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -75,14 +75,14 @@ export const ScaledBoardCanvas = ({
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
width: logicalWidth,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
interface BoardRemoveConfirmationMenuItemProps {
|
||||
label: ReactNode;
|
||||
confirmationLabel: ReactNode;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const BoardRemoveConfirmationMenuItem = ({
|
||||
label,
|
||||
confirmationLabel,
|
||||
onConfirm,
|
||||
}: BoardRemoveConfirmationMenuItemProps) => {
|
||||
const [isConfirmationRequired, setIsConfirmationRequired] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
c="red.6"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
closeMenuOnClick={isConfirmationRequired}
|
||||
onBlur={() => setIsConfirmationRequired(false)}
|
||||
onClick={() => {
|
||||
if (isConfirmationRequired) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationRequired(true);
|
||||
}}
|
||||
>
|
||||
{isConfirmationRequired ? confirmationLabel : label}
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelLeft = 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
@@ -101,22 +101,6 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -162,6 +146,7 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
id={contentId}
|
||||
className={classes.containerBody}
|
||||
h="100%"
|
||||
data-board-container-body
|
||||
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
aria-hidden={isVisuallyCollapsed}
|
||||
inert={isVisuallyCollapsed}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
const menuLeftOffset = parentSection.kind === "container" ? 36 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
@@ -33,14 +32,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
@@ -49,8 +40,8 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
right={menuRightOffset}
|
||||
top={4}
|
||||
left={menuLeftOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
@@ -58,7 +49,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
@@ -69,10 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tContainer("action.remove")}
|
||||
confirmationLabel={tContainer("remove.message")}
|
||||
onConfirm={() => removeContainer({ id: section.id })}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ interface TestPlacement {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: "container" | "item";
|
||||
type: "section" | "item";
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ const placement = (
|
||||
y: number,
|
||||
w = 1,
|
||||
h = 1,
|
||||
kind: TestPlacement["kind"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
|
||||
type: TestPlacement["type"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, type, marker: `metadata:${id}` });
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
@@ -302,6 +302,55 @@ describe("same-grid movement", () => {
|
||||
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
|
||||
});
|
||||
|
||||
test("keeps a container fixed when an item is moved onto it", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0), placement("container", 1, 0, 2, 2, "section")]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test("lets an active container push an item", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "section"), placement("item", 2, 0, 2)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "container" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 2, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "item")).toMatchObject({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test("does not let an active container displace another container", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [
|
||||
placement("active-container", 0, 0, 2, 2, "section"),
|
||||
placement("fixed-container", 2, 0, 2, 2, "section"),
|
||||
]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active-container" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "fixed-container")).toMatchObject({ x: 2, y: 0 });
|
||||
expect(getPlacement(moved.preview, "active-container")).toMatchObject({ x: 2, y: 2 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
|
||||
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
|
||||
@@ -408,9 +457,26 @@ describe("cross-grid movement", () => {
|
||||
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("keeps a destination container fixed when an item enters its grid", () => {
|
||||
const original = state(
|
||||
grid("source", 4, [placement("active", 0, 0)]),
|
||||
grid("target", 4, [placement("container", 1, 0, 2, 2, "section")]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "target",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test("moves a container's owned grid with it and supports commit/cancel", () => {
|
||||
const original = state(
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "section")]),
|
||||
grid("target", 3, []),
|
||||
grid("container-content", 2, [placement("child", 0, 0)], {
|
||||
parentGridId: "source",
|
||||
@@ -421,7 +487,7 @@ describe("cross-grid movement", () => {
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
|
||||
|
||||
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, type: "section" });
|
||||
expect(cancelGridTransaction(moved)).toEqual(original);
|
||||
expect(commitGridTransaction(moved)).toEqual(moved.preview);
|
||||
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
|
||||
@@ -429,8 +495,8 @@ describe("cross-grid movement", () => {
|
||||
|
||||
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "section")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "section")], {
|
||||
parentGridId: "root",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
|
||||
@@ -150,7 +150,8 @@ const previewSameGridMove = <TPlacement extends GridPlacement>(
|
||||
const collisions = grid.placements.filter(
|
||||
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
|
||||
);
|
||||
const displaced = collisions.length === 1 ? collisions[0] : undefined;
|
||||
const displacedCandidate = collisions.length === 1 ? collisions[0] : undefined;
|
||||
const displaced = displacedCandidate && !isContainerPlacement(displacedCandidate) ? displacedCandidate : undefined;
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
@@ -209,8 +210,10 @@ const previewCrossGridMove = <TPlacement extends GridPlacement>(
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the active placement, keeps every non-colliding item at its persisted
|
||||
* coordinates, and pushes only the deterministic collision chain downward.
|
||||
* Keeps stationary containers fixed, resolves the active placement around
|
||||
* those obstacles, and then pushes only the deterministic item collision chain
|
||||
* downward. A container can still be the active placement and push items, but
|
||||
* no active placement can displace another container.
|
||||
*/
|
||||
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
@@ -223,8 +226,24 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
|
||||
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
|
||||
|
||||
const placed: TPlacement[] = [pinned];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
|
||||
const fixedContainers = normalized.filter(
|
||||
(placement) => placement.id !== pinned.id && isContainerPlacement(placement),
|
||||
);
|
||||
const placed: TPlacement[] = [...fixedContainers];
|
||||
let resolvedPinned = pinned;
|
||||
for (;;) {
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolvedPinned, placement));
|
||||
if (collisions.length === 0) break;
|
||||
resolvedPinned = {
|
||||
...resolvedPinned,
|
||||
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
|
||||
};
|
||||
}
|
||||
placed.push(resolvedPinned);
|
||||
|
||||
const candidates = normalized
|
||||
.filter((placement) => placement.id !== pinned.id && !isContainerPlacement(placement))
|
||||
.toSorted(comparePlacements);
|
||||
for (const candidate of candidates) {
|
||||
let resolved = candidate;
|
||||
for (;;) {
|
||||
@@ -245,6 +264,8 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
|
||||
|
||||
const isContainerPlacement = (placement: GridPlacement) => "type" in placement && placement.type === "section";
|
||||
|
||||
const clampMovePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
input: GridMoveInput,
|
||||
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export { getPreferredNestedExitTargetId } from "./targeting";
|
||||
export { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
|
||||
export type { GridTargetGeometry } from "./targeting";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridTargetGeometry } from "./targeting";
|
||||
import { getPreferredNestedExitTargetId } from "./targeting";
|
||||
import { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
@@ -66,3 +66,30 @@ describe("nested grid exit targeting", () => {
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested grid fallback targeting", () => {
|
||||
const grids = [
|
||||
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
|
||||
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
|
||||
];
|
||||
|
||||
test("falls back from a blocked container to its parent canvas", () => {
|
||||
expect(
|
||||
getGridTargetFallbackIds({
|
||||
grids,
|
||||
targetGridId: "container",
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toEqual(["container", "canvas"]);
|
||||
});
|
||||
|
||||
test("does not cross an ineligible hierarchy boundary", () => {
|
||||
expect(
|
||||
getGridTargetFallbackIds({
|
||||
grids,
|
||||
targetGridId: "container",
|
||||
isEligible: (gridId) => gridId !== "canvas",
|
||||
}),
|
||||
).toEqual(["container"]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ export interface GridTargetGeometry {
|
||||
rectangle: RectangleLike;
|
||||
}
|
||||
|
||||
interface GridTargetFallbackInput {
|
||||
grids: readonly Pick<GridTargetGeometry, "id" | "parentGridId">[];
|
||||
targetGridId: string;
|
||||
isEligible: (gridId: string) => boolean;
|
||||
}
|
||||
|
||||
interface PreferredNestedExitTargetInput {
|
||||
grids: readonly GridTargetGeometry[];
|
||||
pointerTargetId: string;
|
||||
@@ -48,6 +54,26 @@ export const getPreferredNestedExitTargetId = ({
|
||||
return current.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tries the visible nested grid first, then its ancestors. A full or too-small
|
||||
* container must not trap an item that can still move in the parent grid.
|
||||
*/
|
||||
export const getGridTargetFallbackIds = ({ grids, targetGridId, isEligible }: GridTargetFallbackInput) => {
|
||||
const byId = new Map(grids.map((grid) => [grid.id, grid]));
|
||||
const ids: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current = byId.get(targetGridId);
|
||||
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
if (!isEligible(current.id)) break;
|
||||
ids.push(current.id);
|
||||
current = current.parentGridId ? byId.get(current.parentGridId) : undefined;
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isSameGridOrAncestor = (
|
||||
candidateId: string,
|
||||
source: GridTargetGeometry,
|
||||
|
||||
@@ -93,6 +93,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-content-mount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||
overflow: clip;
|
||||
}
|
||||
@@ -124,14 +129,52 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-section-kind="empty"][data-rail-placement="main"]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]
|
||||
> [data-board-container-body]
|
||||
> [data-section-id]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]:has(
|
||||
> [data-board-container-body] > [data-section-id] > .board-grid-editor[data-dnd-drop-target="true"]
|
||||
)::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
box-sizing: border-box;
|
||||
content: "";
|
||||
border: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-board-container-collapsed]:has(
|
||||
> [data-board-container-body]
|
||||
> [data-section-id]
|
||||
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"]
|
||||
)::after {
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -143,10 +186,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -155,16 +194,6 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
|
||||
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
@@ -178,8 +207,8 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
|
||||
.board-grid-entry:hover > .board-grid-resize-handle::after,
|
||||
.board-grid-resize-handle:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -270,7 +299,7 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
.board-grid-resize-handle::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
commitGridTransaction,
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getGridTargetFallbackIds,
|
||||
getPreferredNestedExitTargetId,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
@@ -56,7 +56,6 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
|
||||
import type { DndGridEntryProps } from "./grid-entry-memo";
|
||||
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { observeGridDragHandle } from "./grid-drag-handle";
|
||||
import { createGridInteractionStore } from "./grid-interaction-store";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
@@ -195,7 +194,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
document.body.removeAttribute("data-board-grid-interacting");
|
||||
}, [interactionStore]);
|
||||
|
||||
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
|
||||
useLayoutEffect(() => {
|
||||
clearInteraction();
|
||||
return clearInteraction;
|
||||
}, [board.items, board.sections, clearInteraction, currentLayoutId]);
|
||||
|
||||
const registerGrid = useCallback((grid: RegisteredGrid) => {
|
||||
gridsRef.current.set(grid.id, grid);
|
||||
@@ -348,66 +350,56 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinates = getSnappedGridCoordinates({
|
||||
dragShape: shape,
|
||||
target: targetGrid.element.getBoundingClientRect(),
|
||||
columnCount: targetGrid.columnCount,
|
||||
const fallbackIds = getGridTargetFallbackIds({
|
||||
grids: Array.from(gridsRef.current.values()),
|
||||
targetGridId: targetGrid.id,
|
||||
isEligible: (targetGridId) => isDropTargetEligible(source, targetGridId),
|
||||
});
|
||||
if (!coordinates) {
|
||||
showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:coordinates`);
|
||||
return;
|
||||
}
|
||||
const { x, y } = coordinates;
|
||||
const targetAcceptsSource = canAcceptDrop(source, targetGrid.id);
|
||||
const previewKey = `${targetGrid.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
|
||||
if (active.lastPreviewKey === previewKey) return;
|
||||
if (!targetAcceptsSource) {
|
||||
showInvalidPreview(targetGrid.id, previewKey);
|
||||
return;
|
||||
}
|
||||
let invalidPreviewKey = `invalid:${targetGrid.id}:no-candidate`;
|
||||
|
||||
const result = previewGridMove(active.transaction, { targetGridId: targetGrid.id, x, y });
|
||||
if (
|
||||
result.accepted &&
|
||||
doesPreviewDisplaceNestedGridOwner(
|
||||
active.transaction.snapshot,
|
||||
result.transaction.preview,
|
||||
targetGrid.id,
|
||||
active.transaction.activeId,
|
||||
)
|
||||
) {
|
||||
showInvalidPreview(targetGrid.id, `${previewKey}:nested-owner`);
|
||||
return;
|
||||
}
|
||||
active.lastPreviewKey = previewKey;
|
||||
active.targetGridId = targetGrid.id;
|
||||
active.valid = result.accepted;
|
||||
if (!result.accepted) {
|
||||
for (const candidateId of fallbackIds) {
|
||||
const candidate = gridsRef.current.get(candidateId);
|
||||
if (!candidate) continue;
|
||||
const coordinates = getSnappedGridCoordinates({
|
||||
dragShape: shape,
|
||||
target: candidate.element.getBoundingClientRect(),
|
||||
columnCount: candidate.columnCount,
|
||||
});
|
||||
if (!coordinates) {
|
||||
invalidPreviewKey = `invalid:${candidate.id}:coordinates`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = coordinates;
|
||||
const targetAcceptsSource = canAcceptDrop(source, candidate.id);
|
||||
const previewKey = `${candidate.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
|
||||
invalidPreviewKey = previewKey;
|
||||
if (!targetAcceptsSource) continue;
|
||||
|
||||
const result = previewGridMove(active.transaction, { targetGridId: candidate.id, x, y });
|
||||
if (!result.accepted) continue;
|
||||
if (active.lastPreviewKey === previewKey) return;
|
||||
|
||||
active.lastPreviewKey = previewKey;
|
||||
active.targetGridId = candidate.id;
|
||||
active.valid = true;
|
||||
active.transaction = result.transaction;
|
||||
const targetPlacement = result.transaction.preview.grids
|
||||
.find((grid) => grid.id === candidate.id)
|
||||
?.placements.find((placement) => placement.id === sourceData.placement.id);
|
||||
publishPreview({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: targetGrid.id,
|
||||
targetPlacement: null,
|
||||
state: cancelGridTransaction(active.transaction),
|
||||
targetGridId: candidate.id,
|
||||
targetPlacement: targetPlacement ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "drag",
|
||||
valid: false,
|
||||
valid: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
active.transaction = result.transaction;
|
||||
const targetPlacement = result.transaction.preview.grids
|
||||
.find((grid) => grid.id === targetGrid.id)
|
||||
?.placements.find((placement) => placement.id === sourceData.placement.id);
|
||||
publishPreview({
|
||||
activeId: active.transaction.activeId,
|
||||
sourceGridId: active.transaction.sourceGridId,
|
||||
targetGridId: targetGrid.id,
|
||||
targetPlacement: targetPlacement ?? null,
|
||||
state: result.transaction.preview,
|
||||
mode: "drag",
|
||||
valid: true,
|
||||
});
|
||||
showInvalidPreview(targetGrid.id, invalidPreviewKey);
|
||||
},
|
||||
[canAcceptDrop, interactionStore, isDropTargetEligible],
|
||||
);
|
||||
@@ -762,9 +754,9 @@ export default function GridEditor({
|
||||
data: { kind: GRID_TARGET_TYPE, sectionId },
|
||||
collisionDetector: pointerIntersection,
|
||||
collisionPriority: CollisionPriority.Highest + depth,
|
||||
// `accept` only removes the active container's own subtree. Size limits
|
||||
// are validated after collision so an invalid nested target cannot fall
|
||||
// through to an accepting parent grid underneath the pointer.
|
||||
// `accept` removes only the active container's own subtree. Size limits
|
||||
// are evaluated during preview targeting, which can fall back from an
|
||||
// invalid nested grid to an eligible parent.
|
||||
accept: (source) => isDropTargetEligible(source, sectionId),
|
||||
});
|
||||
|
||||
@@ -852,9 +844,8 @@ const DndGridEntryComponent = ({
|
||||
maxRowCount,
|
||||
registerElement,
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer } = useBoardGridPortalHost();
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
@@ -908,8 +899,9 @@ const DndGridEntryComponent = ({
|
||||
shellRef.current = element;
|
||||
registerElement(placement.id, element);
|
||||
draggableRef(element);
|
||||
handleRef(element);
|
||||
},
|
||||
[draggableRef, placement.id, registerElement],
|
||||
[draggableRef, handleRef, placement.id, registerElement],
|
||||
);
|
||||
|
||||
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
|
||||
@@ -918,35 +910,12 @@ const DndGridEntryComponent = ({
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const container = acquireContainer(placement.id);
|
||||
mount.appendChild(container);
|
||||
mount.replaceChildren(container);
|
||||
|
||||
const handleSelector =
|
||||
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
|
||||
return observeGridDragHandle(container, handleSelector, handleRef);
|
||||
}, [acquireContainer, handleRef, placement.id, placement.type]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shell = shellRef.current;
|
||||
if (placement.type !== "section" || !shell) return;
|
||||
|
||||
const handlePointerDownCapture = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
const grip = event.target.closest("[data-grid-container-drag-handle]");
|
||||
if (grip) {
|
||||
const owner = grip.closest('.board-grid-entry[data-grid-item-type="section"]');
|
||||
setIsSectionChromeActive(owner === shell);
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeHandle = event.target.closest(".board-grid-resize-handle");
|
||||
if (!resizeHandle || resizeHandle.parentElement !== shell) {
|
||||
setIsSectionChromeActive(false);
|
||||
}
|
||||
return () => {
|
||||
if (container.parentElement === mount) container.remove();
|
||||
};
|
||||
|
||||
shell.addEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
|
||||
}, [placement.type]);
|
||||
}, [acquireContainer, placement.id, structureRevision]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -959,7 +928,6 @@ const DndGridEntryComponent = ({
|
||||
data-grid-y={placement.y}
|
||||
data-grid-w={placement.w}
|
||||
data-grid-h={placement.h}
|
||||
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
>
|
||||
@@ -968,7 +936,6 @@ const DndGridEntryComponent = ({
|
||||
sectionId={sectionId}
|
||||
placement={placement}
|
||||
label={label}
|
||||
disabled={placement.type === "section" && !isSectionChromeActive}
|
||||
columnCount={columnCount}
|
||||
maxRowCount={maxRowCount}
|
||||
beginResize={beginResize}
|
||||
@@ -987,7 +954,6 @@ interface GridResizeHandlesProps {
|
||||
sectionId: string;
|
||||
placement: SectionGridPlacement;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
beginResize: BoardGridEditorActionsContextValue["beginResize"];
|
||||
@@ -1003,7 +969,6 @@ const GridResizeHandles = ({
|
||||
sectionId,
|
||||
placement,
|
||||
label,
|
||||
disabled,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
beginResize,
|
||||
@@ -1083,15 +1048,7 @@ const GridResizeHandles = ({
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
|
||||
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
|
||||
if (
|
||||
disabled ||
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
pointerStartRef.current ||
|
||||
!visualScale ||
|
||||
!start(direction)
|
||||
)
|
||||
return;
|
||||
if (!event.isPrimary || event.button !== 0 || pointerStartRef.current || !visualScale || !start(direction)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
@@ -1150,7 +1107,6 @@ const GridResizeHandles = ({
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
data-grid-resize-disabled={disabled ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -1281,40 +1237,17 @@ const getPreferredRegisteredGridTarget = (
|
||||
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
|
||||
};
|
||||
|
||||
const doesPreviewDisplaceNestedGridOwner = (
|
||||
snapshot: TransactionalGridState<SectionGridPlacement>,
|
||||
preview: TransactionalGridState<SectionGridPlacement>,
|
||||
targetGridId: string,
|
||||
activeId: string,
|
||||
) => {
|
||||
const ownerIds = new Set(
|
||||
snapshot.grids
|
||||
.filter((grid) => grid.parentGridId === targetGridId && grid.ownerPlacementId !== activeId)
|
||||
.flatMap((grid) => (grid.ownerPlacementId ? [grid.ownerPlacementId] : [])),
|
||||
);
|
||||
if (ownerIds.size === 0) return false;
|
||||
|
||||
const original = snapshot.grids.find((grid) => grid.id === targetGridId);
|
||||
const candidate = preview.grids.find((grid) => grid.id === targetGridId);
|
||||
if (!original || !candidate) return false;
|
||||
const originalById = new Map(original.placements.map((placement) => [placement.id, placement]));
|
||||
return candidate.placements.some((placement) => {
|
||||
const previous = originalById.get(placement.id);
|
||||
return Boolean(previous && ownerIds.has(placement.id) && !areGridPlacementCoordinatesEqual(previous, placement));
|
||||
});
|
||||
};
|
||||
|
||||
const areGridPlacementCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || target === source.handle) return false;
|
||||
const activationElement = source.handle ?? source.element;
|
||||
if (!(target instanceof Element) || target === activationElement) return false;
|
||||
const targetShell = target.closest(".board-grid-entry");
|
||||
if (targetShell && targetShell !== activationElement) return true;
|
||||
const interactive = target.closest(
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.board-grid-resize-handle',
|
||||
);
|
||||
return Boolean(interactive && source.handle?.contains(interactive));
|
||||
return Boolean(interactive && activationElement?.contains(interactive));
|
||||
};
|
||||
|
||||
const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGridPlacement) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
@@ -23,6 +23,7 @@ interface GridPortalHostContextValue {
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
structureRevision: string;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
@@ -50,6 +51,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
@@ -81,8 +83,9 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
structureRevision,
|
||||
}),
|
||||
[acquireContainer, announce, containers, integrations],
|
||||
[acquireContainer, announce, containers, integrations, structureRevision],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -143,26 +146,23 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = persistedEntry;
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section = persistedSection;
|
||||
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
if (!section || !currentLayout) return null;
|
||||
|
||||
const columnCount = getSectionColumnCount(section, currentLayout);
|
||||
if (columnCount < 1) return null;
|
||||
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
|
||||
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
|
||||
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
|
||||
if (!entry) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
...renderedItems.map((item) => item.yOffset + item.height),
|
||||
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
);
|
||||
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
|
||||
|
||||
@@ -170,8 +170,8 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
items: renderedItems,
|
||||
innerSections: renderedInnerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
@@ -183,23 +183,66 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
|
||||
);
|
||||
};
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
const getRenderedSection = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
sectionId: string,
|
||||
visited = new Set<string>(),
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind !== "container") return section;
|
||||
if (visited.has(section.id)) return null;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
|
||||
if (!layout) return null;
|
||||
const parentSection = getRenderedSection(
|
||||
board,
|
||||
currentLayout,
|
||||
layout.parentSectionId,
|
||||
new Set(visited).add(section.id),
|
||||
);
|
||||
if (!parentSection) return null;
|
||||
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
|
||||
if (parentColumnCount < 1) return null;
|
||||
const normalized = normalizePortalEntry(layout, parentColumnCount);
|
||||
|
||||
return {
|
||||
...section,
|
||||
...layout,
|
||||
...normalized,
|
||||
type: "section",
|
||||
};
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
|
||||
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
|
||||
) =>
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
|
||||
const normalizePortalEntry = <
|
||||
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
|
||||
>(
|
||||
entry: TEntry,
|
||||
columnCount: number,
|
||||
) => {
|
||||
const placement = normalizeGridPlacement(
|
||||
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
|
||||
columnCount,
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
};
|
||||
|
||||
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
|
||||
const item = board.items.find((candidate) => candidate.id === entryId);
|
||||
if (item) {
|
||||
|
||||
@@ -172,8 +172,10 @@ export const SectionGrid = ({
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
w={logicalWidth}
|
||||
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
|
||||
style={{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
|
||||
}}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
|
||||
Reference in new issue
Block a user