diff --git a/frontend/src/lib/components/SortableList/sortableList.tsx b/frontend/src/lib/components/SortableList/sortableList.tsx index 2112a877a..bd71a6c25 100644 --- a/frontend/src/lib/components/SortableList/sortableList.tsx +++ b/frontend/src/lib/components/SortableList/sortableList.tsx @@ -24,9 +24,19 @@ export type IsMoveAllowedArgs = { destinationType: ItemType | null; }; -const ITEM_TOP_AND_CENTER_AREA_SIZE_IN_PERCENT = 50; -const GROUP_TOP_AND_CENTER_AREA_SIZE_IN_PERCENT = 30; -const DEFAULT_SCROLL_TIME = 100; +export type SortableListContextType = { + draggedElementId: string | null; + hoveredElementId: string | null; + hoveredArea: HoveredArea | null; + dragPosition: Vec2 | null; +}; + +export const SortableListContext = React.createContext({ + draggedElementId: null, + hoveredElementId: null, + hoveredArea: null, + dragPosition: null, +}); export type SortableListProps = { contentWhenEmpty?: React.ReactNode; @@ -40,6 +50,10 @@ export type SortableListProps = { ) => void; }; +const ITEM_TOP_AND_BOTTOM_AREA_SIZE_IN_PERCENT = 50; +const GROUP_TOP_AND_BOTTOM_AREA_SIZE_IN_PERCENT = 30; +const DEFAULT_SCROLL_TIME = 100; + /** * * @param {SortableListProps} props Object of properties for the SortableList component (see below for details). @@ -76,19 +90,24 @@ export function SortableList(props: SortableListProps): React.ReactNode { } React.useEffect( - function addEventHandlers() { + function addEventListeners() { if (!listDivRef.current) { return; } + if (!mainDivRef.current) { + return; + } + const currentListDivRef = listDivRef.current; + const currentMainDivRef = mainDivRef.current; let pointerDownPosition: Vec2 | null = null; let pointerDownPositionRelativeToElement: Vec2 = { x: 0, y: 0 }; let draggingActive: boolean = false; - let draggedElement: ElementWithInfos | null = null; + let draggedElementInfo: ElementWithInfo | null = null; - let currentlyHoveredElement: HoveredElementWithInfos | null = null; + let currentlyHoveredElementInfo: HoveredElementWithInfo | null = null; let scrollTimeout: ReturnType | null = null; let doScroll: boolean = false; @@ -108,14 +127,20 @@ export function SortableList(props: SortableListProps): React.ReactNode { } const element = sortableListItemProps.element; - draggedElement = { + draggedElementInfo = { element, id: sortableListItemProps.id, type: getItemType(element), - parentId: sortableListItemProps.parentId, - parentType: sortableListItemProps.parentElement - ? getItemType(sortableListItemProps.parentElement) - : null, + parent: + sortableListItemProps.parentElement && sortableListItemProps.parentId + ? { + element: sortableListItemProps.parentElement, + id: sortableListItemProps.parentId, + type: sortableListItemProps.parentElement + ? getItemType(sortableListItemProps.parentElement) + : null, + } + : null, }; pointerDownPosition = { x: e.clientX, y: e.clientY }; @@ -129,6 +154,7 @@ export function SortableList(props: SortableListProps): React.ReactNode { document.addEventListener("pointerup", handlePointerUp); e.preventDefault(); + e.stopPropagation(); } function maybeScroll(position: Vec2) { @@ -208,17 +234,16 @@ export function SortableList(props: SortableListProps): React.ReactNode { // If no element was found, check if the pointer is in the bottom area of the main list const directChildren = elements.filter((el) => el.parentElement === currentListDivRef); - if ( - mainDivRef.current && - rectContainsPoint(mainDivRef.current.getBoundingClientRect(), vec2FromPointerEvent(e)) - ) { - return { element: directChildren[directChildren.length - 1], area: HoveredArea.BOTTOM }; + const mainDivRect = currentMainDivRef.getBoundingClientRect(); + + if (!rectContainsPoint(mainDivRect, vec2FromPointerEvent(e))) { + return null; } - return null; + return { element: directChildren[directChildren.length - 1], area: HoveredArea.BOTTOM }; } - function getItemPositionInGroup(item: HTMLElement): number { + function getItemPositionInGroup(item: HTMLElement, ignoreItem?: HTMLElement): number { let group = item.parentElement?.closest(".sortable-list-group-content"); if (!group || !(group instanceof HTMLElement)) { group = currentListDivRef; @@ -230,6 +255,9 @@ export function SortableList(props: SortableListProps): React.ReactNode { if (!(elm instanceof HTMLElement) || getItemType(elm) === null) { continue; } + if (elm === ignoreItem) { + continue; + } if (group.children[i] === item) { return pos; } @@ -240,10 +268,7 @@ export function SortableList(props: SortableListProps): React.ReactNode { } function handlePointerMove(e: PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (!pointerDownPosition || !draggedElement) { + if (!pointerDownPosition || !draggedElementInfo) { return; } @@ -253,13 +278,16 @@ export function SortableList(props: SortableListProps): React.ReactNode { ) { draggingActive = true; setIsDragging(true); - setDraggedItemId(draggedElement.id); + setDraggedItemId(draggedElementInfo.id); } if (!draggingActive) { return; } + e.preventDefault(); + e.stopPropagation(); + const dx = e.clientX - pointerDownPositionRelativeToElement.x; const dy = e.clientY - pointerDownPositionRelativeToElement.y; setDragPosition({ x: dx, y: dy }); @@ -268,95 +296,115 @@ export function SortableList(props: SortableListProps): React.ReactNode { maybeScroll(point); - if (rectContainsPoint(draggedElement.element.getBoundingClientRect(), point)) { + if (rectContainsPoint(draggedElementInfo.element.getBoundingClientRect(), point)) { // Hovering the dragged element itself - currentlyHoveredElement = null; + currentlyHoveredElementInfo = null; setHoveredItemIdAndArea(null); return; } const hoveredElementAndArea = getHoveredElementAndArea(e); if (!hoveredElementAndArea) { - currentlyHoveredElement = null; + currentlyHoveredElementInfo = null; + setHoveredItemIdAndArea(null); + return; + } + + if (hoveredElementAndArea.element === draggedElementInfo.element) { + currentlyHoveredElementInfo = null; + setHoveredItemIdAndArea(null); + return; + } + + if ( + hoveredElementAndArea.element === getItemParent(draggedElementInfo.element) && + hoveredElementAndArea.area === HoveredArea.HEADER + ) { + // Dragged element should not be moved into its own parent + currentlyHoveredElementInfo = null; setHoveredItemIdAndArea(null); return; } const positionDelta = hoveredElementAndArea.area === HoveredArea.TOP ? 0 : 1; - const newPosition = getItemPositionInGroup(hoveredElementAndArea.element) + positionDelta; - const currentPosition = getItemPositionInGroup(draggedElement.element); - const hoveredItemParentGroupId = getItemParentGroupId(hoveredElementAndArea.element); - - console.debug( - "newPosition", - newPosition, - "currentPosition", - currentPosition, - "positionDelta", - positionDelta, - "draggedElementParentGroupId", - draggedElement.parentId, - "hoveredItemParentGroupId", - hoveredItemParentGroupId - ); + const newPosition = + getItemPositionInGroup(hoveredElementAndArea.element, draggedElementInfo.element) + positionDelta; + const currentPosition = getItemPositionInGroup(draggedElementInfo.element); + const draggedElementParentId = draggedElementInfo.parent?.id ?? null; if ( - draggedElement.parentId === getItemParentGroupId(hoveredElementAndArea.element) && - (newPosition === currentPosition || newPosition === currentPosition + 1) + hoveredElementAndArea.area !== HoveredArea.HEADER && + draggedElementParentId === getGroupId(getItemParent(hoveredElementAndArea.element)) && + newPosition === currentPosition ) { - currentlyHoveredElement = null; + currentlyHoveredElementInfo = null; setHoveredItemIdAndArea(null); return; } - const { element: hoveredElement, area } = hoveredElementAndArea; - const itemType = getItemType(hoveredElement); - if (itemType === ItemType.ITEM && (area === HoveredArea.CENTER || area === HoveredArea.HEADER)) { - currentlyHoveredElement = null; + const itemType = getItemType(hoveredElementAndArea.element); + if ( + itemType === ItemType.ITEM && + (hoveredElementAndArea.area === HoveredArea.CENTER || + hoveredElementAndArea.area === HoveredArea.HEADER) + ) { + currentlyHoveredElementInfo = null; setHoveredItemIdAndArea(null); return; } - const parentElement = getItemParent(hoveredElement); + const hoveredElementId = hoveredElementAndArea.element.dataset.itemId ?? ""; + const parentElement = getItemParent(hoveredElementAndArea.element); const parentType = parentElement ? getItemType(parentElement) : null; let destinationType = parentType; - let destinationId = getItemParentGroupId(hoveredElement); + let destinationId = getGroupId(parentElement); if (itemType === ItemType.GROUP) { - if (area === HoveredArea.HEADER) { - destinationType = ItemType.GROUP; - destinationId = hoveredElement.dataset.itemId ?? ""; - } - if (area === HoveredArea.CENTER) { + if ( + hoveredElementAndArea.area === HoveredArea.HEADER || + hoveredElementAndArea.area === HoveredArea.CENTER + ) { destinationType = ItemType.GROUP; - destinationId = hoveredElement.dataset.itemId ?? ""; + destinationId = hoveredElementId ?? ""; } } if ( isMoveAllowed !== undefined && !isMoveAllowed({ - movedItemId: draggedElement.id, - movedItemType: draggedElement.type, - originId: draggedElement.parentId, - originType: draggedElement.parentType, + movedItemId: draggedElementInfo.id, + movedItemType: draggedElementInfo.type, + originId: draggedElementInfo.parent?.id ?? null, + originType: draggedElementInfo.parent?.type ?? null, destinationId, destinationType, }) ) { - currentlyHoveredElement = null; + currentlyHoveredElementInfo = null; setHoveredItemIdAndArea(null); return; } - setHoveredItemIdAndArea({ id: hoveredElement.dataset.itemId ?? "", area }); - currentlyHoveredElement = { - element: hoveredElement, - id: hoveredElement.dataset.itemId ?? "", + + setHoveredItemIdAndArea({ + id: hoveredElementId, + area: hoveredElementAndArea.area, + }); + + let parent: Omit | null = null; + if (parentElement && destinationId) { + parent = { + element: parentElement, + id: destinationId, + type: parentType, + }; + } + + currentlyHoveredElementInfo = { + ...hoveredElementAndArea, + id: hoveredElementId, type: itemType, - area, - parentId: destinationId, - parentType: destinationType, + parent, }; } @@ -365,20 +413,22 @@ export function SortableList(props: SortableListProps): React.ReactNode { return; } - if (!draggedElement || !currentlyHoveredElement) { + if (!draggedElementInfo || !currentlyHoveredElementInfo) { return; } + const draggedElementParent = getItemParent(draggedElementInfo.element); + if (isMoveAllowed !== undefined) { - const parentElement = getItemParent(currentlyHoveredElement.element); + const parentElement = getItemParent(currentlyHoveredElementInfo.element); const parentType = parentElement ? getItemType(parentElement) : null; if ( !isMoveAllowed({ - movedItemId: draggedElement.id, - movedItemType: draggedElement.type, - originId: getItemParentGroupId(draggedElement.element), - originType: getItemType(draggedElement.element), - destinationId: getItemParentGroupId(currentlyHoveredElement.element), + movedItemId: draggedElementInfo.id, + movedItemType: draggedElementInfo.type, + originId: getGroupId(draggedElementParent), + originType: getItemType(draggedElementInfo.element), + destinationId: getGroupId(parentElement), destinationType: parentType, }) ) { @@ -387,22 +437,24 @@ export function SortableList(props: SortableListProps): React.ReactNode { } if ( - currentlyHoveredElement.area === HoveredArea.HEADER || - currentlyHoveredElement.area === HoveredArea.CENTER + currentlyHoveredElementInfo.area === HoveredArea.HEADER || + currentlyHoveredElementInfo.area === HoveredArea.CENTER ) { - const originId = getItemParentGroupId(draggedElement.element); - const destinationId = currentlyHoveredElement.id; + const originId = getGroupId(draggedElementParent); + const destinationId = currentlyHoveredElementInfo.id; const position = 0; - onItemMoved(draggedElement.id, originId, destinationId, position); + onItemMoved(draggedElementInfo.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; + const originId = getGroupId(draggedElementParent); + const destinationId = getGroupId(getItemParent(currentlyHoveredElementInfo.element)); + const positionDelta = currentlyHoveredElementInfo.area === HoveredArea.TOP ? 0 : 1; + const position = + getItemPositionInGroup(currentlyHoveredElementInfo.element, draggedElementInfo.element) + + positionDelta; - onItemMoved(draggedElement.id, originId, destinationId, position); + onItemMoved(draggedElementInfo.id, originId, destinationId, position); } function handlePointerUp() { @@ -413,8 +465,8 @@ export function SortableList(props: SortableListProps): React.ReactNode { function cancelDragging() { draggingActive = false; pointerDownPosition = null; - draggedElement = null; - currentlyHoveredElement = null; + draggedElementInfo = null; + currentlyHoveredElementInfo = null; setIsDragging(false); setDraggedItemId(null); setHoveredItemIdAndArea(null); @@ -438,7 +490,7 @@ export function SortableList(props: SortableListProps): React.ReactNode { document.addEventListener("keydown", handleKeyDown); window.addEventListener("blur", handleWindowBlur); - return function removeEventHandlers() { + return function removeEventListeners() { currentListDivRef.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); @@ -501,7 +553,7 @@ export function SortableList(props: SortableListProps): React.ReactNode { ref={scrollDivRef} onScroll={handleScroll} > -
+
{makeChildren()}
@@ -529,15 +581,14 @@ export enum HoveredArea { CENTER = "center", } -type ElementWithInfos = { +type ElementWithInfo = { element: HTMLElement; id: string; type: ItemType | null; - parentId: string | null; - parentType: ItemType | null; + parent: Omit | null; }; -type HoveredElementWithInfos = ElementWithInfos & { +type HoveredElementWithInfo = ElementWithInfo & { area: HoveredArea; }; @@ -546,20 +597,6 @@ type HoveredItemIdAndArea = { area: HoveredArea; }; -export type SortableListContextType = { - draggedElementId: string | null; - hoveredElementId: string | null; - hoveredArea: HoveredArea | null; - dragPosition: Vec2 | null; -}; - -export const SortableListContext = React.createContext({ - draggedElementId: null, - hoveredElementId: null, - hoveredArea: null, - dragPosition: null, -}); - function assertTargetIsSortableListItemAndExtractProps( target: EventTarget | null ): { element: HTMLElement; id: string; parentElement: HTMLElement | null; parentId: string | null } | null { @@ -613,21 +650,24 @@ function getItemType(item: HTMLElement): ItemType | null { } function getHoveredAreaOfItem(item: HTMLElement, e: PointerEvent): HoveredArea { - let factor = ITEM_TOP_AND_CENTER_AREA_SIZE_IN_PERCENT / 100; - if (getItemType(item) === ItemType.GROUP) { - factor = GROUP_TOP_AND_CENTER_AREA_SIZE_IN_PERCENT / 100; - } + let factor = ITEM_TOP_AND_BOTTOM_AREA_SIZE_IN_PERCENT / 100; const rect = item.getBoundingClientRect(); + const topAreaTop = rect.top; - const topAreaBottom = rect.top + factor * rect.height; + const bottomAreaBottom = rect.bottom; + + let topAreaBottom = rect.top + factor * rect.height; + let bottomAreaTop = rect.bottom - factor * rect.height; + if (getItemType(item) === ItemType.GROUP) { + factor = GROUP_TOP_AND_BOTTOM_AREA_SIZE_IN_PERCENT / 100; + topAreaBottom = rect.top + Math.min(10, factor * rect.height); + bottomAreaTop = rect.bottom - Math.min(10, factor * rect.height); + } if (e.clientY >= topAreaTop && e.clientY <= topAreaBottom) { return HoveredArea.TOP; } - const bottomAreaTop = rect.bottom - factor * rect.height; - const bottomAreaBottom = rect.bottom; - if (e.clientY >= bottomAreaTop && e.clientY <= bottomAreaBottom) { return HoveredArea.BOTTOM; } @@ -643,7 +683,11 @@ function getHoveredAreaOfItem(item: HTMLElement, e: PointerEvent): HoveredArea { return HoveredArea.CENTER; } -function getItemParent(item: HTMLElement): HTMLElement | null { +function getItemParent(item: HTMLElement | null): HTMLElement | null { + if (!item) { + return null; + } + const group = item.parentElement?.closest(".sortable-list-group"); if (!group || !(group instanceof HTMLElement)) { return null; @@ -651,12 +695,8 @@ function getItemParent(item: HTMLElement): HTMLElement | null { return group; } -function getItemParentGroupId(item: HTMLElement): string | null { - const group = getItemParent(item); - if (!group) { - return null; - } - return group.dataset.itemId ?? null; +function getGroupId(group: HTMLElement | null): string | null { + return group?.dataset.itemId ?? null; } function getDragElementsRecursively(parentElement: HTMLElement): HTMLElement[] { diff --git a/frontend/src/main.css b/frontend/src/main.css index db87d72ad..d735e0b37 100644 --- a/frontend/src/main.css +++ b/frontend/src/main.css @@ -19,19 +19,19 @@ body { border-radius: 2px; width: 4px; height: 4px; - background-color: rgba(190, 190, 190, 0); + background-color: rgba(190, 190, 190, 0.3); transition: background-color 0.5s ease-in-out; } *:hover > *::-webkit-scrollbar-thumb { width: 4px; height: 4px; - background-color: rgba(190, 190, 190, 0.6); + background-color: rgba(190, 190, 190, 0.8); transition: background-color 0.5s ease-in-out; } *:hover > *::-webkit-scrollbar-thumb:hover { - background-color: rgba(134, 134, 134, 0.8); + background-color: rgba(134, 134, 134, 0.9); transition: background-color 0.5s ease-in-out; }