import { uniqueId } from 'lodash'; import { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react'; import { SetOptional } from 'type-fest'; import { ContentOutlineItemBaseProps, ITEM_TYPES } from './ContentOutlineItem'; export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps { id: string; ref: HTMLElement | null; color?: string; children?: ContentOutlineItemContextProps[]; } type RegisterFunction = (outlineItem: SetOptional) => string; export interface ContentOutlineContextProps { outlineItems: ContentOutlineItemContextProps[]; register: RegisterFunction; unregister: (id: string) => void; unregisterAllChildren: ( parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, childType: ITEM_TYPES ) => void; updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void; updateItem: (id: string, properties: Partial>) => void; } interface ContentOutlineContextProviderProps { children: ReactNode; /** * used to resort children of an outline item when the dependencies change * e.g. when the order of query rows changes on drag and drop */ refreshDependencies?: unknown[]; } interface ParentlessItems { [panelId: string]: ContentOutlineItemContextProps[]; } export const ContentOutlineContext = createContext(undefined); export function ContentOutlineContextProvider({ children, refreshDependencies }: ContentOutlineContextProviderProps) { const [outlineItems, setOutlineItems] = useState([]); const parentlessItemsRef = useRef({}); const register: RegisterFunction = useCallback((outlineItem) => { // Allow the caller to define unique ID so the outlineItem can be differentiated const id = outlineItem.id ? outlineItem.id : uniqueId(`${outlineItem.panelId}-${outlineItem.title}-${outlineItem.icon}_`); setOutlineItems((prevItems) => { if (outlineItem.level === 'root') { const parentlessItems = parentlessItemsRef.current[outlineItem.panelId] || []; // if item has children in parentlessItemsRef and they are filters, // modify each child to have ref = outlineItem.ref // so that clicking on the filter will also bring the parent item into view if (parentlessItems.length > 0) { parentlessItemsRef.current[outlineItem.panelId].forEach((item) => { if (item.type === 'filter') { item.ref = outlineItem.ref; } }); } // remove children from parentlessItemsRef parentlessItemsRef.current[outlineItem.panelId] = []; const updatedItems = [ ...prevItems, { ...outlineItem, id, children: parentlessItems, }, ]; return updatedItems.sort(sortElementsByDocumentPosition); } if (outlineItem.level === 'child') { let siblingWithSameTitleFound = false; // items with type filter should not have siblings with the same title // look at all parentless items and check if there is a sibling with the same title Object.keys(parentlessItemsRef.current).forEach((key) => { const siblingWithSameTitle = parentlessItemsRef.current[key].find( (item) => item.title === outlineItem.title && outlineItem.type === 'filter' && outlineItem.panelId === item.panelId ); if (siblingWithSameTitle) { siblingWithSameTitleFound = true; return; } }); if (siblingWithSameTitleFound) { return [...prevItems]; } const parentIndex = prevItems.findIndex( (item) => item.panelId === outlineItem.panelId && item.level === 'root' ); if (parentIndex === -1) { const parentlessItemSibling = Object.keys(parentlessItemsRef.current).find( (key) => key === outlineItem.panelId ); if (parentlessItemSibling) { parentlessItemsRef.current[outlineItem.panelId].push({ ...outlineItem, id, }); } else { parentlessItemsRef.current[outlineItem.panelId] = [ { ...outlineItem, id, }, ]; } return [...prevItems]; } const newItems = [...prevItems]; const parent = { ...newItems[parentIndex] }; // look at all registered items inside items parent and check if there is // a filter sibling with the same title const siblingWithSameTitle = parent.children?.find( (item) => item.title === outlineItem.title && outlineItem.type === 'filter' && outlineItem.panelId === item.panelId ); // check if sibling's highlight property has updated if (siblingWithSameTitle && siblingWithSameTitle.highlight !== outlineItem.highlight) { parent.children?.map((child) => { if (child.title === siblingWithSameTitle?.title) { child.highlight = outlineItem.highlight; } }); return [...prevItems]; } else if (siblingWithSameTitle) { return [...prevItems]; } let ref = outlineItem.ref; if (outlineItem.type === 'filter') { ref = parent.ref; } let childrenUpdated = [{ ...outlineItem, id, ref }, ...(parent.children || [])]; if (!outlineItem.childOnTop) { childrenUpdated = sortItems(childrenUpdated); } newItems[parentIndex] = { ...parent, children: childrenUpdated, }; return newItems; } return [...prevItems]; }); return id; }, []); const unregister = useCallback((id: string) => { setOutlineItems((prevItems) => prevItems .filter((item) => item.id !== id) .map((item) => { if (item.children) { item.children = item.children.filter((child) => child.id !== id); } return item; }) ); }, []); const updateOutlineItems = useCallback((newItems: ContentOutlineItemContextProps[]) => { setOutlineItems(newItems); }, []); const updateItem = useCallback((id: string, properties: Partial>) => { setOutlineItems((prevItems) => prevItems.map((item) => { if (item.id === id) { return { ...item, ...properties, }; } return item; }) ); }, []); const unregisterAllChildren = useCallback( (parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, childType: ITEM_TYPES) => { setOutlineItems((prevItems) => { const parentId = parentIdGetter(prevItems); if (!parentId) { return prevItems; } return prevItems.map((item) => { if (item.id === parentId) { item.children = item.children?.filter((child) => child.type !== childType); } return item; }); }); }, [] ); useEffect(() => { setOutlineItems((prevItems) => { const newItems = [...prevItems]; for (const item of newItems) { const sortedItems = sortItems(item.children || []); item.children = sortedItems; } return newItems; }); }, [refreshDependencies]); return ( {children} ); } function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps, b: ContentOutlineItemContextProps) { if (a.ref && b.ref) { const diff = a.ref.compareDocumentPosition(b.ref); if (diff === Node.DOCUMENT_POSITION_PRECEDING) { return 1; } else if (diff === Node.DOCUMENT_POSITION_FOLLOWING) { return -1; } } return 0; } function sortItems(outlineItems: ContentOutlineItemContextProps[]): ContentOutlineItemContextProps[] { const [skipSort, sortable] = outlineItems.reduce< [ContentOutlineItemContextProps[], ContentOutlineItemContextProps[]] >( (acc, item) => { item.childOnTop ? acc[0].push(item) : acc[1].push(item); return acc; }, [[], []] ); sortable.sort(sortElementsByDocumentPosition); return [...skipSort, ...sortable]; } export function useContentOutlineContext() { return useContext(ContentOutlineContext); }