Files
Homarr/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
homarr-renovate[bot] a87c937b69 fix(deps): update dependency eslint-plugin-react-hooks to v5 (#1280)
* fix(deps): update dependency eslint-plugin-react-hooks to v5

* fix: lint issues after reenabling hook rules

* fix: format issues

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2024-10-16 21:43:51 +02:00

356 lines
12 KiB
TypeScript

import type { MutableRefObject, RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { useItemActions } from "../../items/item-actions";
import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs {
wrapper: RefObject<HTMLDivElement>;
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
gridstack: MutableRefObject<GridStack | undefined>;
}
interface UseGristackReturnType {
refs: UseGridstackRefs;
}
/**
* When the size of a gridstack changes we need to update the css variables
* so the gridstack items are displayed correctly
* @param wrapper gridstack wrapper
* @param gridstack gridstack object
* @param width width of the section (column count)
* @param height height of the section (row count)
* @param isDynamic if the section is dynamic
*/
const handleResizeChange = (
wrapper: HTMLDivElement,
gridstack: GridStack,
width: number,
height: number,
isDynamic: boolean,
) => {
wrapper.style.setProperty("--gridstack-column-count", width.toString());
wrapper.style.setProperty("--gridstack-row-count", height.toString());
let cellHeight = wrapper.clientWidth / width;
if (isDynamic) {
cellHeight = wrapper.clientHeight / height;
}
if (!isDynamic) {
document.body.style.setProperty("--gridstack-cell-size", cellHeight.toString());
}
gridstack.cellHeight(cellHeight);
};
export const useGridstack = (section: Omit<Section, "items">, itemIds: string[]): UseGristackReturnType => {
const [isEditMode] = useEditMode();
const markAsReady = useMarkSectionAsReady();
const { moveAndResizeItem, moveItemToSection } = useItemActions();
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
// define reference for wrapper - is used to calculate the width of the wrapper
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
// references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>();
const board = useRequiredBoard();
const columnCount =
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
? section.width
: board.columnCount;
useCssVariableConfiguration({
columnCount,
gridRef,
wrapperRef,
width,
height,
isDynamic: section.kind === "dynamic",
});
const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items
if (itemRefKeys.length !== itemIds.length) {
// Remove items that are not in the itemIds
// Otherwise when an item is removed and then another item is added, this foreach below will not run.
itemRefKeys.forEach((id) => {
if (!itemIds.includes(id)) {
delete itemRefs.current[id];
}
});
itemIds.forEach((id) => {
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
});
}
// Toggle the gridstack to be static or not based on the edit mode
useEffect(() => {
gridRef.current?.setStatic(!isEditMode);
}, [isEditMode]);
const onChange = useCallback(
(changedNode: GridStackNode) => {
const id = changedNode.el?.getAttribute("data-id");
const type = changedNode.el?.getAttribute("data-type");
if (!id || !type) return;
if (type === "item") {
// Updates the react-query state
moveAndResizeItem({
itemId: id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
xOffset: changedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: changedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: changedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: changedNode.h!,
});
return;
}
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
xOffset: changedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: changedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: changedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: changedNode.h!,
});
return;
}
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
const id = addedNode.el?.getAttribute("data-id");
const type = addedNode.el?.getAttribute("data-type");
if (!id || !type) return;
if (type === "item") {
// Updates the react-query state
moveItemToSection({
itemId: id,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
xOffset: addedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: addedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: addedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: addedNode.h!,
});
return;
}
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
xOffset: addedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: addedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: addedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: addedNode.h!,
});
return;
}
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
useEffect(() => {
const isReady = initializeGridstack({
section,
itemIds,
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
sectionColumnCount: columnCount,
});
// If the section is ready mark it as ready
// When all sections are ready the board is ready and will get visible
if (isReady) {
markAsReady(section.id);
}
// Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemIds.length, columnCount]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
*/
useEffect(() => {
if (!isEditMode) return;
const currentGrid = gridRef.current;
// Add listener for moving items around in a wrapper
currentGrid?.on("change", (_, nodes) => {
nodes.forEach(onChange);
// For all dynamic section items that changed we want to update the inner gridstack
nodes
.filter((node) => node.el?.getAttribute("data-type") === "section")
.forEach((node) => {
const dynamicInnerGrid = node.el?.querySelector<GridHTMLElement>('.grid-stack[data-kind="dynamic"]');
if (!dynamicInnerGrid?.gridstack) return;
handleResizeChange(
dynamicInnerGrid as HTMLDivElement,
dynamicInnerGrid.gridstack,
node.w ?? 1,
node.h ?? 1,
true,
);
});
});
// Add listener for moving items in config from one wrapper to another
currentGrid?.on("added", (_, nodes) => {
nodes.forEach(onAdd);
});
return () => {
currentGrid?.off("change");
currentGrid?.off("added");
};
}, [isEditMode, onAdd, onChange]);
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
// We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
useEffect(() => {
if (!sectionHeight) return;
gridRef.current?.row(sectionHeight);
}, [sectionHeight]);
return {
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
};
};
interface UseCssVariableConfiguration {
gridRef: UseGridstackRefs["gridstack"];
wrapperRef: UseGridstackRefs["wrapper"];
width: number;
height: number;
columnCount: number;
isDynamic: boolean;
}
/**
* This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items
* @see gridstack.scss
* @param gridRef reference to the gridstack object
* @param wrapperRef reference to the wrapper of the gridstack
* @param width width of the section
* @param height height of the section
* @param columnCount column count of the gridstack
*/
const useCssVariableConfiguration = ({
gridRef,
wrapperRef,
width,
height,
columnCount,
isDynamic,
}: UseCssVariableConfiguration) => {
const onResize = useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
handleResizeChange(
wrapperRef.current,
gridRef.current,
gridRef.current.getColumn(),
gridRef.current.getRow(),
isDynamic,
);
}, [wrapperRef, gridRef, isDynamic]);
useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
wrapperRef.current.style.setProperty("--gridstack-column-count", gridRef.current.getColumn().toString());
wrapperRef.current.style.setProperty("--gridstack-row-count", gridRef.current.getRow().toString());
let cellHeight = wrapperRef.current.clientWidth / gridRef.current.getColumn();
if (isDynamic) {
cellHeight = wrapperRef.current.clientHeight / gridRef.current.getRow();
}
gridRef.current.cellHeight(cellHeight);
}, [wrapperRef, gridRef, isDynamic]);
// Define widget-width by calculating the width of one column with mainRef width and column count
useEffect(() => {
onResize();
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
const wrapper = wrapperRef.current;
wrapper?.addEventListener("resize", onResize);
return () => {
if (typeof window === "undefined") return;
window.removeEventListener("resize", onResize);
wrapper?.removeEventListener("resize", onResize);
};
}, [wrapperRef, gridRef, onResize]);
// Handle resize of inner sections when there size changes
useEffect(() => {
onResize();
}, [width, height, onResize]);
// Define column count by using the sectionColumnCount
useEffect(() => {
wrapperRef.current?.style.setProperty("--gridstack-column-count", columnCount.toString());
}, [columnCount, wrapperRef]);
};