Files
homarr/apps/nextjs/src/components/board/items/item-move-modal.tsx
T

374 lines
13 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
export type MovableEntry = Pick<
SectionItem | ContainerSectionItem,
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
>;
export interface OpenItemMoveModalInput {
entry: MovableEntry;
sourceSectionId: string;
}
interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
}
interface MoveTarget {
id: string;
label: string;
columnCount: number;
maxRowCount: number | null;
}
interface MoveTargetLabels {
canvas: string;
container: string;
leftRail: string;
rightRail: string;
numbered: (name: string, index: number) => string;
located: (name: string, location: string, index: number) => string;
}
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
name: string;
location?: string;
};
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
);
const targets = getMoveTargets(
board,
currentLayoutId,
entry,
{
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
},
registeredGridEditors,
);
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
if (!initialTarget) {
throw new Error("No valid dashboard section is available for this item");
}
const form = useZodForm(
z.object({
sectionId: z.string().min(1),
xOffset: z.number().int().min(0).max(32767),
yOffset: z.number().int().min(0).max(32767),
width: z.number().int().min(1).max(32767),
height: z.number().int().min(1).max(32767),
}),
{
initialValues: {
sectionId: initialTarget.id,
xOffset: entry.xOffset,
yOffset: entry.yOffset,
width: entry.width,
height: entry.height,
},
},
);
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
const handleSubmit = useCallback(
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
const target = targetById.get(values.sectionId);
if (!target) return;
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
const height = Math.max(minimumSize.height, values.height);
const nextPlacement: SectionGridPlacement = {
id: entry.id,
type: entry.type,
x: Math.min(values.xOffset, target.columnCount - width),
y: values.yOffset,
w: width,
h: height,
};
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
(placement) => placement.id !== entry.id,
);
const resolvedTarget = resolvePinnedGridCollisions(
[...targetPlacements, nextPlacement],
nextPlacement,
target.columnCount,
);
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
form.setFieldError("height", tMoveResize("keyboard.boundary"));
return;
}
const snapshots =
sourceSectionId === target.id
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
: [
{
layoutId: currentLayoutId,
sectionId: sourceSectionId,
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
(placement) => placement.id !== entry.id,
),
},
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
];
commitSectionGrids(snapshots);
actions.closeModal();
focusMovedEntry(entry.id, target.id);
},
[
actions,
board,
commitSectionGrids,
currentLayoutId,
entry,
form,
minimumSize,
sourceSectionId,
targetById,
tMoveResize,
],
);
return (
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
<Stack>
<Select
data={targets.map((target) => ({ value: target.id, label: target.label }))}
label={tMoveResize("field.section.label")}
data-autofocus
allowDeselect={false}
searchable
{...form.getInputProps("sectionId")}
/>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.xOffset.label")}
min={0}
max={selectedTarget.columnCount - 1}
{...form.getInputProps("xOffset")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.yOffset.label")}
min={0}
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
{...form.getInputProps("yOffset")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.width.label")}
min={minimumSize.width}
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
{...form.getInputProps("width")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={tMoveResize("field.height.label")}
min={minimumSize.height}
max={
selectedTarget.maxRowCount === null
? undefined
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
}
{...form.getInputProps("height")}
/>
</Grid.Col>
</Grid>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.moveResize.title");
},
size: modalSizeForm,
});
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const registeredGridEditors = useRegisteredGridEditors();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
return useCallback(
(input: OpenItemMoveModalInput) => {
openModal({
...input,
board,
currentLayoutId,
commitSectionGrids,
registeredGridEditors,
});
},
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
);
};
export const getMoveTargets = (
board: Board,
layoutId: string,
entry: MovableEntry,
labels: MoveTargetLabels,
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry);
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
if (section.kind === "empty") {
const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return [];
const registeredEditor = registeredGridEditors?.get(section.id);
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
return minimum.width <= columnCount
? [
{
id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount,
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
},
]
: [];
}
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return [];
if (
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
minimum.width > layout.width ||
minimum.height > layout.height
) {
return [];
}
return [
{
id: section.id,
name: section.options.title || labels.container,
columnCount: layout.width,
maxRowCount: layout.height,
},
];
});
return candidates.map(({ name, location, ...target }, index) => ({
...target,
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
}));
};
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) throw new Error(`Section "${sectionId}" was not found`);
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
return columnCount;
};
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
}
return null;
};
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
if (entry.type === "item") return { width: 1, height: 1 };
const children = getSectionGridPlacements(board, layoutId, entry.id);
return {
width: Math.max(1, ...children.map((child) => child.x + child.w)),
height: Math.max(1, ...children.map((child) => child.y + child.h)),
};
};
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
let current: string | undefined = candidateId;
const visited = new Set<string>();
while (current && !visited.has(current)) {
if (current === ancestorId) return true;
visited.add(current);
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
if (!section || section.kind !== "container") return false;
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
}
return false;
};
const focusMovedEntry = (entryId: string, sectionId: string) => {
let attempts = 0;
const tryFocus = () => {
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
(candidate) => candidate.dataset.gridSectionId === sectionId,
);
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
(candidate) => candidate.dataset.gridItemId === entryId,
);
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
if (editorEntry) {
editorEntry.focus();
return;
}
attempts += 1;
if (attempts < 10) requestAnimationFrame(tryFocus);
};
requestAnimationFrame(tryFocus);
};