import { Keyboard, Mouse } from '@vev/utils'; import { at, clamp, difference, keyBy, last, sum, uniq } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BoxProps, SilkeBox } from '../silke-box'; import { SilkeNestedSortableItem } from './silke-nested-sortable-item'; import styles from './silke-nested-sortable-list.scss'; import { generatedComputedStates, getIndentLevel, getMouseHorizontalIndentTarget, getParents, hasState, isParentOfSelf, Item, ItemState, RenderFunction, SilkeSortableIconComponent, } from './silke-nested-sortable-utils'; type SilkeNestedSortableListProps = { /** Items in the root to start from, fallback is to use items with no parents */ rootItems?: string[]; items: Item[]; selected?: string[]; collapsed?: (item: Item) => boolean; classNameItem?: string | ((item: Item) => string); children: RenderFunction; replaceIcon?: SilkeSortableIconComponent; onSort?: (updateList: Item[], parentKey: string | undefined, childIndex: number) => void; canHaveChildren?: (item: Item) => boolean; canDrag?: (selected: string[]) => boolean; canSort?: ( parentKey: string | undefined, childIndex: number, parents: string[], selected: string[], ) => boolean | number; /** Triggered when collapsed arrow is pressed,gives the updated collapse list */ onCollapse?: (item: Item, doCollapse: boolean) => void; onItemClick?: (e: MouseEvent, item: Item) => void; onItemDoubleClick?: (e: React.MouseEvent, item: Item) => void; onItemMouseEnter?: (e: React.MouseEvent, item: Item) => void; onItemMouseLeave?: (e: React.MouseEvent, item: Item) => void; onItemContextMenu?: (e: React.MouseEvent, item: Item) => void; } & Omit; export function SilkeNestedSortableList({ rootItems, items, selected, collapsed, children, className, classNameItem, replaceIcon, onSort, canHaveChildren, canSort, canDrag, onCollapse, onItemClick, onItemDoubleClick, onItemMouseEnter, onItemMouseLeave, onItemContextMenu, ...rest }: SilkeNestedSortableListProps) { const ref = useRef(null); const [pendingClickItem, setPendingClickItem] = useState>(); const [startDrag, setStartDrag] = useState(false); const [dropState, setDropState] = useState<{ index: number; state: number; childIndex: number; top: number; }>(); let cl = styles.list; if (className) cl += ' ' + className; if (dropState) cl += ' ' + styles.dragging; const [computedStates, parentMap, itemMap] = useMemo(() => { const itemMap = keyBy(items, 'key'); const parentMap: { [key: string]: string } = {}; for (const item of items) { if (item?.children) { for (const childKey of item.children) parentMap[childKey] = item.key; } } const rootItemKeys = rootItems || items.filter((item) => !(item.key in parentMap)).map((item) => item.key); const computedStates = generatedComputedStates( itemMap, rootItemKeys, selected, collapsed, canHaveChildren, ); return [computedStates, parentMap, itemMap]; }, [items, selected, collapsed, rootItems, canHaveChildren]); const handleCollapse = useCallback( (item: Item) => { onCollapse?.(item, !collapsed?.(item)); }, [collapsed, onCollapse], ); const handleItemMouseDown = useCallback( (e: React.MouseEvent, item: Item) => { if (e.button === 2 && onItemContextMenu) return onItemContextMenu(e, item); if (e.button !== 0) return; const shouldDragBeDone = onSort && (!canDrag || canDrag(selected || [])); // If already selected we have to wait to see if the user wants to drag or not, will be handled in mouse up of drag // If not selected we can handle the mouse event immediately if (shouldDragBeDone && selected?.includes(item.key)) { setPendingClickItem(item); } else { setPendingClickItem(undefined); onItemClick?.(e.nativeEvent, item); } if (shouldDragBeDone) setStartDrag(true); }, [onItemClick, onSort, canDrag, onItemContextMenu, selected], ); useEffect(() => { if (selected?.length) { // Timing out for a while to not make the list jumpy when user clicks fast around const timeout = setTimeout(() => { const el = ref.current?.querySelector('.' + styles.selected) as HTMLElement | undefined; if (el) (el as any).scrollIntoViewIfNeeded?.(); }, 500); return () => clearTimeout(timeout); } }, [selected?.join(',')]); useEffect(() => { const listEl = ref.current; if (!startDrag || !listEl) return; const itemElements = listEl.querySelectorAll('.' + styles.item); const offsets: [offsetTop: number, height: number][] = []; itemElements?.forEach((el) => { offsets.push([el.offsetTop, el.offsetHeight]); }); Keyboard.onKey('Escape', () => { setStartDrag(false); setDropState(undefined); }); let dragState: 'pending' | 'dragging' = 'pending'; let dropState: { index: number; state: number; parentKey: string; childIndex: number; top: number; }; const handleMouseMove = (e: MouseEvent) => { if (!selected || !selected.length) return; if (dragState === 'pending') { if (Mouse.getDownDist() > 10) dragState = 'dragging'; } else { const { top } = listEl.getBoundingClientRect(); const cursorOffsetY = e.pageY - top; let index = -1; if (cursorOffsetY > offsets[0][0]) { for (index = 0; index < offsets.length; index++) { const [top, height] = offsets[index]; if (cursorOffsetY <= top + height) break; } } let placementState: number = ItemState.placeBefore; let dropLineTopPos = 0; if (index === -1) { placementState = ItemState.placeBefore; index = 0; } else if (index === computedStates.length) { placementState = ItemState.placeAfter; index = computedStates.length - 1; // Place dropline after last element in list dropLineTopPos = sum(last(offsets) || []); } else { const [targetState, targetItem] = computedStates[index]; const allowChildren = canHaveChildren ? canHaveChildren(targetItem) : Boolean(targetItem?.children); const offset = offsets[clamp(index, 0, computedStates.length - 1)]; const isTargetSelected = hasState(targetState, ItemState.selected); const isTargetCollapsed = hasState(targetState, ItemState.collapsed) || !targetItem?.children?.length; const insideBottomPos = offset[0] + offset[1] + (isTargetCollapsed ? -10 : 0); dropLineTopPos = offset[0]; if ( allowChildren && !isTargetSelected && cursorOffsetY > offset[0] + 10 && cursorOffsetY < insideBottomPos ) { placementState = ItemState.placeInside; } else if (cursorOffsetY > offset[0] + offset[1] / 2) { placementState = ItemState.placeAfter; dropLineTopPos += offset[1]; } } let [, targetItem] = computedStates[index]; let parentKey: string; let childIndex = 0; if (placementState === ItemState.placeInside) { parentKey = targetItem.key; } else { if (placementState === ItemState.placeAfter) { const mouseIndentLevels = Math.ceil(e.pageX - Mouse.pageDownX) / 24; targetItem = getMouseHorizontalIndentTarget( targetItem, mouseIndentLevels, itemMap, parentMap, ); } parentKey = parentMap[targetItem.key]; const parent = itemMap[parentKey]; childIndex = parent?.children?.indexOf(targetItem.key) || 0; if (placementState === ItemState.placeAfter) childIndex++; } const parents = [parentKey, ...getParents(parentKey, parentMap)].filter(Boolean); const allowSort = !canSort || canSort(parentKey, childIndex, parents, selected); const selectionParentOfSelf = selected.some((key) => isParentOfSelf(parentMap[targetItem.key], key, parentMap), ); if (!allowSort || selectionParentOfSelf) placementState |= ItemState.placementInvalid; const indent = parents.length; dropState = { index, state: placementState | indent, childIndex, parentKey, top: dropLineTopPos, }; setDropState(dropState); } }; const handleMouseUp = (e: MouseEvent) => { setStartDrag(false); setDropState(undefined); if (dragState === 'pending') { if (pendingClickItem && onItemClick) onItemClick(e, pendingClickItem); return; } if (dropState && selected && !hasState(dropState.state, ItemState.placementInvalid)) { const { parentKey, childIndex, index, state } = dropState; const newItems = items.slice(); let currentChildIndex = childIndex; for (let i = 0; i < newItems.length; i++) { const item = newItems[i]; const { children } = item; if (children) { const newChildren = difference(children, selected); // if child difference then some children were removed so need to update item if (newChildren.length !== children.length) { newItems[i] = { ...item, children: newChildren }; } } if (item.key === parentKey) { const newChildren = (newItems[i].children || []).slice(); // Have to offset index if child existed in the children array above the position we're inserting at currentChildIndex -= selected.reduce((offset, key) => { const index = children ? children.indexOf(key) : -1; return index !== -1 && index < currentChildIndex ? offset + 1 : offset; }, 0); newChildren.splice(currentChildIndex, 0, ...uniq(selected)); newItems[i] = { ...newItems[i], children: newChildren }; } } // If no parent we notify about the list change by just sorting the main list if (!parentKey) { let atIndex = index; if (hasState(state, ItemState.placeAfter) && atIndex > 0) atIndex++; const selectedItems = selected.map((key) => { const index = newItems.findIndex((item) => item.key === key); const [item] = newItems.splice(index, 1); if (index < atIndex) atIndex--; return item; }); newItems.splice(atIndex, 0, ...selectedItems); } onSort?.(newItems, parentKey, childIndex); } }; Mouse.on('mousemove', handleMouseMove); Mouse.on('mouseup', handleMouseUp); return () => { Mouse.off('mousemove', handleMouseMove); Mouse.off('mouseup', handleMouseUp); }; }, [startDrag, selected, computedStates, onItemClick, canHaveChildren, canSort, parentMap]); const isClassNameItemAFunction = typeof classNameItem === 'function'; return (
{startDrag && dropState && ( )} {computedStates.map(([state, item], index) => ( {item.render || children} ))}
); } function DropPoint({ state, top }: { state: number; top: number }) { // Handled by outlining the element if (hasState(state, ItemState.placeInside)) return null; const indent = getIndentLevel(state); let cl = styles.dropPoint; if (hasState(state, ItemState.placementInvalid)) cl += ' ' + styles.placementInvalid; if (indent) cl += ' ' + styles[`indent${indent}`]; return (
); }