diff --git a/frontend/src/lib/components/DragList/dragList.tsx b/frontend/src/lib/components/DragList/dragList.tsx index 7f1e7c0a7..b093b8a5c 100644 --- a/frontend/src/lib/components/DragList/dragList.tsx +++ b/frontend/src/lib/components/DragList/dragList.tsx @@ -5,19 +5,41 @@ import { MANHATTAN_LENGTH, rectContainsPoint } from "@lib/utils/geometry"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { Vec2, point2Distance } from "@lib/utils/vec2"; -import { DragListContainerProps } from "./dragListContainer"; +import { isEqual } from "lodash"; + +import { DragListGroupProps } from "./dragListGroup"; import { DragListItemProps } from "./dragListItem"; +export enum HoveredArea { + TOP = "top", + BOTTOM = "bottom", + HEADER = "header", + CENTER = "center", +} + +export type DragListContextType = { + draggedElementId: string | null; + hoveredElementId: string | null; + hoveredArea: HoveredArea | null; + dragPosition: Vec2 | null; +}; + +export const DragListContext = React.createContext({ + draggedElementId: null, + hoveredElementId: null, + hoveredArea: null, + dragPosition: null, +}); + export type DragListProps = { contentWhenEmpty?: React.ReactNode; - children: React.ReactElement[]; - onItemsOrderChange?: (containerId: string | null, itemsOrder: string[]) => void; - onItemMove?: (itemId: string, containerId: string | null, position: number) => void; + children: React.ReactElement[]; + onItemMove?: (itemId: string, originId: string | null, destinationId: string | null, position: number) => void; }; function assertTargetIsDragListItemAndExtractProps( target: EventTarget | null -): { element: HTMLElement; id: string } | null { +): { element: HTMLElement; id: string; parentId: string | null } | null { if (!target) { return null; } @@ -32,27 +54,32 @@ function assertTargetIsDragListItemAndExtractProps( return null; } - const dragListItem = element.closest(".drag-list-element"); - if (!dragListItem) { + const dragListElement = element.closest(".drag-list-element"); + if (!dragListElement) { return null; } - if (!(dragListItem instanceof HTMLElement)) { + if (!(dragListElement instanceof HTMLElement)) { return null; } - const id = dragListItem.dataset.itemId; + const id = dragListElement.dataset.itemId; if (!id) { return null; } - return { element, id }; -} + if ( + dragListElement.parentElement && + dragListElement.parentElement instanceof HTMLElement && + dragListElement.parentElement.classList.contains("drag-list-group") + ) { + const parentId = dragListElement.parentElement.dataset.itemId; + if (parentId) { + return { element: dragListElement, id, parentId }; + } + } -enum HoveredArea { - TOP = "top", - BOTTOM = "bottom", - CENTER = "center", + return { element: dragListElement, id, parentId: null }; } enum ItemType { @@ -68,212 +95,366 @@ type HoveredItemIdAndArea = { const ELEMENT_TOP_AND_CENTER_AREA_SIZE_IN_PX = 10; export function DragList(props: DragListProps): React.ReactNode { + const { onItemMove } = props; + const [isDragging, setIsDragging] = React.useState(false); const [draggedItemId, setDraggedItemId] = React.useState(null); const [hoveredItemIdAndArea, setHoveredItemIdAndArea] = React.useState(null); const [dragPosition, setDragPosition] = React.useState({ x: 0, y: 0 }); const [currentScrollPosition, setCurrentScrollPosition] = React.useState(0); - const [droppable, setDroppable] = React.useState(false); + const [prevChildren, setPrevChildren] = React.useState< + React.ReactElement[] + >(props.children); const listDivRef = React.useRef(null); const scrollDivRef = React.useRef(null); const upperScrollDivRef = React.useRef(null); const lowerScrollDivRef = React.useRef(null); - React.useEffect(function handleMount() { - if (!listDivRef.current) { - return; + if (!isEqual(prevChildren, props.children)) { + setPrevChildren(props.children); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = currentScrollPosition; } + } - const currentListDivRef = listDivRef.current; - - let pointerDownPosition: Vec2 | null = null; - let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; - let draggingActive: boolean = false; - let itemId: string | null = null; - - let scrollTimeout: ReturnType | null = null; - let doScroll: boolean = false; - let currentScrollTime = 100; - - function handlePointerDown(e: PointerEvent) { - const target = e.target; - if (!target) { + React.useEffect( + function handleMount() { + if (!listDivRef.current) { return; } - const dragListItemProps = assertTargetIsDragListItemAndExtractProps(target); - if (!dragListItemProps) { - return; - } + const currentListDivRef = listDivRef.current; + + let pointerDownPosition: Vec2 | null = null; + let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; + let draggingActive: boolean = false; + let draggedElement: { + element: HTMLElement; + id: string; + } | null = null; + + let currentlyHoveredElement: { + element: HTMLElement; + id: string; + area: HoveredArea; + } | null = null; + + let scrollTimeout: ReturnType | null = null; + let doScroll: boolean = false; + let currentScrollTime = 100; + + function handlePointerDown(e: PointerEvent) { + const target = e.target; + if (!target) { + return; + } - const element = dragListItemProps.element; - itemId = dragListItemProps.id; + const dragListItemProps = assertTargetIsDragListItemAndExtractProps(target); + if (!dragListItemProps) { + return; + } - pointerDownPosition = { x: e.clientX, y: e.clientY }; - draggingActive = false; - setIsDragging(true); + const element = dragListItemProps.element; + draggedElement = dragListItemProps; - pointerDownPositionRelativeToElement = { - x: e.clientX - element.getBoundingClientRect().left, - y: e.clientY - element.getBoundingClientRect().top, - }; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); + pointerDownPosition = { x: e.clientX, y: e.clientY }; + draggingActive = false; + setIsDragging(true); - setIsDragging(true); - } + pointerDownPositionRelativeToElement = { + x: e.clientX - element.getBoundingClientRect().left, + y: e.clientY - element.getBoundingClientRect().top, + }; + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); - function maybeScroll(position: Vec2) { - if ( - upperScrollDivRef.current === null || - lowerScrollDivRef.current === null || - scrollDivRef.current === null - ) { - return; + setIsDragging(true); } - if (scrollTimeout) { - clearTimeout(scrollTimeout); - currentScrollTime = 100; + function maybeScroll(position: Vec2) { + if ( + upperScrollDivRef.current === null || + lowerScrollDivRef.current === null || + scrollDivRef.current === null + ) { + return; + } + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + currentScrollTime = 100; + } + + if (rectContainsPoint(upperScrollDivRef.current.getBoundingClientRect(), position)) { + doScroll = true; + scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); + } else if (rectContainsPoint(lowerScrollDivRef.current.getBoundingClientRect(), position)) { + doScroll = true; + scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); + } else { + doScroll = false; + } } - if (rectContainsPoint(upperScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); - } else if (rectContainsPoint(lowerScrollDivRef.current.getBoundingClientRect(), position)) { - doScroll = true; - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); - } else { - doScroll = false; + function scrollUpRepeatedly() { + currentScrollTime = Math.max(10, currentScrollTime - 5); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = Math.max(0, scrollDivRef.current.scrollTop - 10); + } + if (doScroll) { + scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); + } } - } - function scrollUpRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.max(0, scrollDivRef.current.scrollTop - 10); + function scrollDownRepeatedly() { + currentScrollTime = Math.max(10, currentScrollTime - 5); + if (scrollDivRef.current) { + scrollDivRef.current.scrollTop = Math.min( + scrollDivRef.current.scrollHeight, + scrollDivRef.current.scrollTop + 10 + ); + } + if (doScroll) { + scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); + } } - if (doScroll) { - scrollTimeout = setTimeout(scrollUpRepeatedly, currentScrollTime); + + function getDragElementsRecursively(parent?: HTMLElement): HTMLElement[] { + const items: HTMLElement[] = []; + const parentElement = parent ?? currentListDivRef; + + for (const child of parentElement.children) { + if (child instanceof HTMLElement && child.classList.contains("drag-list-item")) { + items.push(child); + } + if (child instanceof HTMLElement && child.classList.contains("drag-list-group")) { + items.push(child); + const content = child.querySelector(".drag-list-group-content"); + if (content && content instanceof HTMLElement) { + items.push(...getDragElementsRecursively(content)); + } + } + } + return items; } - } - function scrollDownRepeatedly() { - currentScrollTime = Math.max(10, currentScrollTime - 5); - if (scrollDivRef.current) { - scrollDivRef.current.scrollTop = Math.min( - scrollDivRef.current.scrollHeight, - scrollDivRef.current.scrollTop + 10 - ); + function getHoveredElement(e: PointerEvent): HTMLElement | null { + const items = getDragElementsRecursively(); + for (const item of items) { + if (rectContainsPoint(item.getBoundingClientRect(), { x: e.clientX, y: e.clientY })) { + const type = getItemType(item); + if (type === ItemType.CONTAINER) { + const content = item.querySelector(".drag-list-group-content"); + if ( + content && + rectContainsPoint(content.getBoundingClientRect(), { x: e.clientX, y: e.clientY }) + ) { + continue; + } + } + + return item; + } + } + + return null; } - if (doScroll) { - scrollTimeout = setTimeout(scrollDownRepeatedly, currentScrollTime); + + function getItemType(item: HTMLElement): ItemType | null { + if (item.classList.contains("drag-list-item")) { + return ItemType.ITEM; + } else if (item.classList.contains("drag-list-group")) { + return ItemType.CONTAINER; + } + return null; } - } - function getItems(): HTMLElement[] { - return Array.from(currentListDivRef.children).filter((child) => { - return child instanceof HTMLElement && child.classList.contains("drag-list-item"); - }) as HTMLElement[]; - } + function getHoveredAreaOfItem(item: HTMLElement, e: PointerEvent): HoveredArea { + const rect = item.getBoundingClientRect(); + const topAreaTop = rect.top; + const topAreaBottom = rect.top + ELEMENT_TOP_AND_CENTER_AREA_SIZE_IN_PX; - function getHoveredItem(e: PointerEvent): HTMLElement | null { - const items = getItems(); - for (const item of items) { - if (rectContainsPoint(item.getBoundingClientRect(), { x: e.clientX, y: e.clientY })) { - return item; + if (e.clientY >= topAreaTop && e.clientY <= topAreaBottom) { + return HoveredArea.TOP; } - } - return null; - } + const bottomAreaTop = rect.bottom - ELEMENT_TOP_AND_CENTER_AREA_SIZE_IN_PX; + const bottomAreaBottom = rect.bottom; - function getHoveredItemType(item: HTMLElement): ItemType | null { - if (item.classList.contains("drag-list-item")) { - return ItemType.ITEM; - } else if (item.classList.contains("drag-list-container")) { - return ItemType.CONTAINER; - } - return null; - } + if (e.clientY >= bottomAreaTop && e.clientY <= bottomAreaBottom) { + return HoveredArea.BOTTOM; + } + + const headerElement = item.querySelector(".drag-list-item-header"); + if (!headerElement) { + return HoveredArea.CENTER; + } + + const headerRect = headerElement.getBoundingClientRect(); + if (rectContainsPoint(headerRect, { x: e.clientX, y: e.clientY })) { + return HoveredArea.HEADER; + } - function getHoveredAreaOfItem(item: HTMLElement, e: PointerEvent): HoveredArea { - const rect = item.getBoundingClientRect(); - const topAreaTop = rect.top; - const topAreaBottom = rect.top + ELEMENT_TOP_AND_CENTER_AREA_SIZE_IN_PX; - const bottomAreaTop = rect.bottom - ELEMENT_TOP_AND_CENTER_AREA_SIZE_IN_PX; - const bottomAreaBottom = rect.bottom; - - if (e.clientY >= topAreaTop && e.clientY <= topAreaBottom) { - return HoveredArea.TOP; - } else if (e.clientY >= bottomAreaTop && e.clientY <= bottomAreaBottom) { - return HoveredArea.BOTTOM; - } else { return HoveredArea.CENTER; } - } - function handlePointerMove(e: PointerEvent) { - if (!pointerDownPosition || !itemId) { - return; + function getItemParentGroupId(item: HTMLElement): string | null { + const group = item.parentElement?.closest(".drag-list-group"); + if (!group || !(group instanceof HTMLElement)) { + return null; + } + return group.dataset.itemId ?? null; } - if ( - !draggingActive && - point2Distance(pointerDownPosition, { x: e.clientX, y: e.clientY }) > MANHATTAN_LENGTH - ) { - draggingActive = true; - setDraggedItemId(itemId); - } + function getItemPositionInGroup(item: HTMLElement): number { + let group = item.parentElement?.closest(".drag-list-group-content"); + if (!group || !(group instanceof HTMLElement)) { + group = currentListDivRef; + } - if (!draggingActive) { - return; + let pos = 0; + for (let i = 0; i < group.children.length; i++) { + const elm = group.children[i]; + if (!(elm instanceof HTMLElement) || getItemType(elm) === null) { + continue; + } + if (group.children[i] === item) { + return pos; + } + pos++; + } + + throw new Error("Item not found in group"); } - const dx = e.clientX - pointerDownPositionRelativeToElement.x; - const dy = e.clientY - pointerDownPositionRelativeToElement.y; - setDragPosition({ x: dx, y: dy }); + function handlePointerMove(e: PointerEvent) { + if (!pointerDownPosition || !draggedElement) { + return; + } - const point: Vec2 = { x: e.clientX, y: e.clientY }; + if ( + !draggingActive && + point2Distance(pointerDownPosition, { x: e.clientX, y: e.clientY }) > MANHATTAN_LENGTH + ) { + draggingActive = true; + setDraggedItemId(draggedElement.id); + } - maybeScroll(point); + if (!draggingActive) { + return; + } - const hoveredItem = getHoveredItem(e); - if (hoveredItem && hoveredItem instanceof HTMLElement) { - const area = getHoveredAreaOfItem(hoveredItem, e); - const itemType = getHoveredItemType(hoveredItem); - if (itemType === ItemType.ITEM && area === HoveredArea.CENTER) { + const dx = e.clientX - pointerDownPositionRelativeToElement.x; + const dy = e.clientY - pointerDownPositionRelativeToElement.y; + setDragPosition({ x: dx, y: dy }); + + const point: Vec2 = { x: e.clientX, y: e.clientY }; + + maybeScroll(point); + + if (rectContainsPoint(draggedElement.element.getBoundingClientRect(), point)) { + // Hovering the dragged element itself + currentlyHoveredElement = null; setHoveredItemIdAndArea(null); return; } - setHoveredItemIdAndArea({ id: hoveredItem.dataset.itemId ?? "", area }); - } else { + + const hoveredElement = getHoveredElement(e); + if (hoveredElement && hoveredElement instanceof HTMLElement) { + const area = getHoveredAreaOfItem(hoveredElement, e); + const itemType = getItemType(hoveredElement); + if (itemType === ItemType.ITEM && (area === HoveredArea.CENTER || area === HoveredArea.HEADER)) { + currentlyHoveredElement = null; + setHoveredItemIdAndArea(null); + return; + } + console.debug(hoveredElement, area); + setHoveredItemIdAndArea({ id: hoveredElement.dataset.itemId ?? "", area }); + currentlyHoveredElement = { + element: hoveredElement, + id: hoveredElement.dataset.itemId ?? "", + area, + }; + } else { + currentlyHoveredElement = null; + setHoveredItemIdAndArea(null); + } + } + + function maybeCallItemMoveCallback() { + if (!onItemMove) { + return; + } + + if (!draggedElement || !currentlyHoveredElement) { + return; + } + + if (currentlyHoveredElement.area === HoveredArea.CENTER) { + return; + } + + if (currentlyHoveredElement.area === HoveredArea.HEADER) { + const originId = getItemParentGroupId(draggedElement.element); + const destinationId = currentlyHoveredElement.id; + const position = 0; + onItemMove(draggedElement.id, originId, destinationId, position); + return; + } + + const originId = getItemParentGroupId(draggedElement.element); + const destinationId = getItemParentGroupId(currentlyHoveredElement.element); + const positionDelta = currentlyHoveredElement.area === HoveredArea.TOP ? 0 : 1; + const position = getItemPositionInGroup(currentlyHoveredElement.element) + positionDelta; + + onItemMove(draggedElement.id, originId, destinationId, position); + } + + function handlePointerUp() { + maybeCallItemMoveCallback(); + cancelDragging(); + } + + function cancelDragging() { + draggingActive = false; + pointerDownPosition = null; + draggedElement = null; + currentlyHoveredElement = null; + setIsDragging(false); + setDraggedItemId(null); setHoveredItemIdAndArea(null); + + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); } - } - function handlePointerUp() { - draggingActive = false; - pointerDownPosition = null; - itemId = null; - setIsDragging(false); - setDraggedItemId(null); - setHoveredItemIdAndArea(null); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + cancelDragging(); + } + } - currentListDivRef.addEventListener("pointerdown", handlePointerDown); + function handleWindowBlur() { + cancelDragging(); + } - return function handleUnmount() { - currentListDivRef.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - setIsDragging(false); - setDraggedItemId(null); - }; - }, []); + currentListDivRef.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", handleWindowBlur); + + return function handleUnmount() { + currentListDivRef.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", handleWindowBlur); + setIsDragging(false); + setDraggedItemId(null); + }; + }, + [onItemMove, props.children] + ); function handleScroll(e: React.UIEvent) { setCurrentScrollPosition(e.currentTarget.scrollTop); @@ -286,36 +467,17 @@ export function DragList(props: DragListProps): React.ReactNode { continue; } if (child.type.name === "DragListItem") { - if (child.props.id === hoveredItemIdAndArea?.id && hoveredItemIdAndArea.area === HoveredArea.TOP) { - children.push(); - } children.push( React.cloneElement(child, { key: child.props.id, - isDragging: child.props.id === draggedItemId, - dragPosition, }) ); - if (child.props.id === hoveredItemIdAndArea?.id && hoveredItemIdAndArea.area === HoveredArea.BOTTOM) { - children.push(); - } } else { - if (child.props.id === hoveredItemIdAndArea?.id && hoveredItemIdAndArea.area === HoveredArea.TOP) { - children.push(); - } children.push( React.cloneElement(child, { key: child.props.id, - isDragging: child.props.id === draggedItemId, - dragPosition, - isHovered: - child.props.id === hoveredItemIdAndArea?.id && - hoveredItemIdAndArea.area === HoveredArea.CENTER, }) ); - if (child.props.id === hoveredItemIdAndArea?.id && hoveredItemIdAndArea.area === HoveredArea.BOTTOM) { - children.push(); - } } } return children; @@ -323,34 +485,41 @@ export function DragList(props: DragListProps): React.ReactNode { return (
-
-
-
-
- {makeChildren()} +
+
+
+
+ {makeChildren()} +
-
- {isDragging && - createPortal( -
- )} -
- ); -} - -function DragListDropIndicator() { - return ( -
-
+ {isDragging && + createPortal( +
+ )} +
); } diff --git a/frontend/src/lib/components/DragList/dragListContainer.tsx b/frontend/src/lib/components/DragList/dragListContainer.tsx deleted file mode 100644 index 889882e9f..000000000 --- a/frontend/src/lib/components/DragList/dragListContainer.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; - -import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; -import { createPortal } from "@lib/utils/createPortal"; -import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { Vec2 } from "@lib/utils/vec2"; -import { DragIndicator } from "@mui/icons-material"; - -import { DragListItemProps } from "./dragListItem"; - -export type DragListContainerProps = { - id: string; - isDragging?: boolean; - isHovered?: boolean; - dragPosition?: Vec2; - icon?: React.ReactNode; - title: string; - startAdornment?: React.ReactNode; - endAdornment?: React.ReactNode; - contentWhenEmpty?: React.ReactNode; - children: React.ReactElement[]; -}; - -export function DragListContainer(props: DragListContainerProps): React.ReactNode { - const divRef = React.useRef(null); - const boundingClientRect = useElementBoundingRect(divRef); - - return ( -
-
- - {props.isDragging && - props.dragPosition && - createPortal( -
- -
- )} - {props.children} -
- ); -} - -type ItemHeaderProps = { - icon?: React.ReactNode; - title: string; - startAdornment?: React.ReactNode; - endAdornment?: React.ReactNode; -}; - -function ItemHeader(props: ItemHeaderProps): React.ReactNode { - return ( -
-
- -
-
- {props.icon} -
{props.title}
- {props.endAdornment} -
-
- ); -} diff --git a/frontend/src/lib/components/DragList/dragListDropIndicator.tsx b/frontend/src/lib/components/DragList/dragListDropIndicator.tsx new file mode 100644 index 000000000..7f6f72d08 --- /dev/null +++ b/frontend/src/lib/components/DragList/dragListDropIndicator.tsx @@ -0,0 +1,7 @@ +export function DragListDropIndicator() { + return ( +
+
+
+ ); +} diff --git a/frontend/src/lib/components/DragList/dragListGroup.tsx b/frontend/src/lib/components/DragList/dragListGroup.tsx new file mode 100644 index 000000000..67c519ed0 --- /dev/null +++ b/frontend/src/lib/components/DragList/dragListGroup.tsx @@ -0,0 +1,116 @@ +import React from "react"; + +import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { createPortal } from "@lib/utils/createPortal"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { DragIndicator, ExpandLess, ExpandMore } from "@mui/icons-material"; + +import { DragListContext, HoveredArea } from "./dragList"; +import { DragListDropIndicator } from "./dragListDropIndicator"; +import { DragListItemProps } from "./dragListItem"; + +export type DragListGroupProps = { + id: string; + icon?: React.ReactNode; + title: string; + startAdornment?: React.ReactNode; + endAdornment?: React.ReactNode; + contentWhenEmpty?: React.ReactNode; + children: React.ReactElement[]; +}; + +export function DragListGroup(props: DragListGroupProps): React.ReactNode { + const [isExpanded, setIsExpanded] = React.useState(true); + + const divRef = React.useRef(null); + const boundingClientRect = useElementBoundingRect(divRef); + const dragListContext = React.useContext(DragListContext); + + const isHovered = dragListContext.hoveredElementId === props.id; + const isHeaderHovered = isHovered && dragListContext.hoveredArea === HoveredArea.HEADER; + const isDragging = dragListContext.draggedElementId === props.id; + const dragPosition = dragListContext.dragPosition; + + function handleToggleExpanded() { + setIsExpanded(!isExpanded); + } + + return ( + <> + {isHovered && dragListContext.hoveredArea === HoveredArea.TOP && } +
+
+
+ {isDragging && + dragPosition && + createPortal( +
+
+
+ )} +
+ {props.children.length === 0 ? props.contentWhenEmpty : props.children} +
+
+ {isHovered && dragListContext.hoveredArea === HoveredArea.BOTTOM && } + + ); +} + +type HeaderProps = { + title: string; + expanded: boolean; + onToggleExpanded?: () => void; + icon?: React.ReactNode; + startAdornment?: React.ReactNode; + endAdornment?: React.ReactNode; +}; + +function Header(props: HeaderProps): React.ReactNode { + return ( +
+
+ +
+
+ {props.expanded ? : } +
+
+ {props.icon} +
{props.title}
+ {props.endAdornment} +
+
+ ); +} diff --git a/frontend/src/lib/components/DragList/dragListItem.tsx b/frontend/src/lib/components/DragList/dragListItem.tsx index dd4c711bc..9befaa237 100644 --- a/frontend/src/lib/components/DragList/dragListItem.tsx +++ b/frontend/src/lib/components/DragList/dragListItem.tsx @@ -3,13 +3,13 @@ import React from "react"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { createPortal } from "@lib/utils/createPortal"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { Vec2 } from "@lib/utils/vec2"; import { DragIndicator } from "@mui/icons-material"; +import { DragListContext, HoveredArea } from "./dragList"; +import { DragListDropIndicator } from "./dragListDropIndicator"; + export type DragListItemProps = { id: string; - isDragging?: boolean; - dragPosition?: Vec2; icon?: React.ReactNode; title: string; startAdornment?: React.ReactNode; @@ -21,51 +21,59 @@ export function DragListItem(props: DragListItemProps): React.ReactNode { const divRef = React.useRef(null); const boundingClientRect = useElementBoundingRect(divRef); + const dragListContext = React.useContext(DragListContext); + + const isHovered = dragListContext.hoveredElementId === props.id; + const isDragging = dragListContext.draggedElementId === props.id; + const dragPosition = dragListContext.dragPosition; + return ( -
+ <> + {isHovered && dragListContext.hoveredArea === HoveredArea.TOP && }
- - {props.isDragging && - props.dragPosition && - createPortal( -
- -
- )} -
{props.children}
-
+ className={resolveClassNames("drag-list-element drag-list-item flex flex-col relative")} + data-item-id={props.id} + ref={divRef} + > +
+
+ {isDragging && + dragPosition && + createPortal( +
+
+
+ )} +
{props.children}
+
+ {isHovered && dragListContext.hoveredArea === HoveredArea.BOTTOM && } + ); } -type ItemHeaderProps = { +type HeaderProps = { icon?: React.ReactNode; title: string; startAdornment?: React.ReactNode; endAdornment?: React.ReactNode; }; -function ItemHeader(props: ItemHeaderProps): React.ReactNode { +function Header(props: HeaderProps): React.ReactNode { return ( -
+
diff --git a/frontend/src/lib/components/DragList/index.ts b/frontend/src/lib/components/DragList/index.ts index 2ebef788d..67b3c2119 100644 --- a/frontend/src/lib/components/DragList/index.ts +++ b/frontend/src/lib/components/DragList/index.ts @@ -1,7 +1,7 @@ export { DragList } from "./dragList"; export { DragListItem } from "./dragListItem"; -export { DragListContainer } from "./dragListContainer"; +export { DragListGroup } from "./dragListGroup"; export type { DragListProps } from "./dragList"; export type { DragListItemProps } from "./dragListItem"; -export type { DragListContainerProps } from "./dragListContainer"; +export type { DragListGroupProps } from "./dragListGroup"; diff --git a/frontend/src/modules/MyModule2/settings.tsx b/frontend/src/modules/MyModule2/settings.tsx index 29665f802..2d8a01631 100644 --- a/frontend/src/modules/MyModule2/settings.tsx +++ b/frontend/src/modules/MyModule2/settings.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { DragListContainer, DragListItem } from "@lib/components/DragList"; +import { DragListGroup, DragListItem } from "@lib/components/DragList"; import { DragList } from "@lib/components/DragList/dragList"; import { Input } from "@lib/components/Input"; import { Label } from "@lib/components/Label"; @@ -9,36 +9,139 @@ import { useAtom } from "jotai"; import { textAtom } from "./atoms"; +type Item = { + id: string; + type: "item" | "group"; + title: string; + children: Item[]; +}; + export const Settings = () => { const [atomText, setAtomText] = useAtom(textAtom); + const [items, setItems] = React.useState([ + { + id: "1", + type: "item", + title: "Item 1", + children: [], + }, + { + id: "2", + type: "item", + title: "Item 2", + children: [], + }, + { + id: "3", + type: "group", + title: "Group 1", + children: [ + { + id: "4", + type: "item", + title: "Item 3", + children: [], + }, + { + id: "5", + type: "item", + title: "Item 4", + children: [], + }, + ], + }, + ]); function handleAtomTextChange(event: React.ChangeEvent) { setAtomText(event.target.value); } + function handleItemMove(itemId: string, originId: string | null, destinationid: string | null, position: number) { + const newItems = [...items]; + + const item = findItemById(itemId, newItems); + if (!item) { + return; + } + + const origin = originId ? findItemById(originId, newItems) : null; + let originArr: Item[] = []; + if (origin) { + originArr = origin.children; + } else { + originArr = newItems; + } + + const destination = findItemById(destinationid!, newItems); + let destinationArr: Item[] = []; + if (destination) { + destinationArr = destination.children; + } else { + destinationArr = newItems; + } + + originArr.splice( + originArr.findIndex((i) => i.id === itemId), + 1 + ); + + if (position === -1) { + destinationArr.unshift(item); + } else { + destinationArr.splice(position, 0, item); + } + + setItems(newItems); + } + + function makeChildren(items: Item[]): React.ReactElement[] { + return items.map((item) => { + if (item.type === "item") { + return ( + + {item.title} + + ); + } else { + return ( + No items
} + > + {makeChildren(item.children)} + + ); + } + }); + } + return ( <> - - - Item 1 - - - Item 2 - - - - Item 3 - - - Item 4 - - + + {makeChildren(items)} ); }; Settings.displayName = "Settings"; + +function findItemById(id: string, items: Item[]): Item | null { + for (const item of items) { + if (item.id === id) { + return item; + } + if (item.children.length > 0) { + const foundItem = findItemById(id, item.children); + if (foundItem) { + return foundItem; + } + } + } + return null; +}