import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { fetchAncestorChildren, useGetRootSidebarPagesQuery, usePageQuery, useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; import { ActionIcon, Box, Menu, rem } from "@mantine/core"; import { IconArrowRight, IconChevronDown, IconChevronRight, IconCopy, IconDotsVertical, IconFileDescription, IconFileExport, IconLink, IconPlus, IconPointFilled, IconTrash, } from "@tabler/icons-react"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import clsx from "clsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { appendNodeChildren, buildTree, buildTreeWithChildren, updateTreeNodeIcon, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { getPageBreadcrumbs, getPageById, getSidebarPages, } from "@/features/page/services/page-service.ts"; import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { queryClient } from "@/main.tsx"; import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { useClipboard, useDisclosure, useElementSize, useMergedRef, } from "@mantine/hooks"; import { dfs } from "react-arborist/dist/module/utils"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; import MovePageModal from "../../components/move-page-modal.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import CopyPageModal from "../../components/copy-page-modal.tsx"; interface SpaceTreeProps { spaceId: string; readOnly: boolean; } const openTreeNodesAtom = atom({}); export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const { pageSlug } = useParams(); const { data, setData, controllers } = useTreeMutation>(spaceId); const { data: pagesData, hasNextPage, fetchNextPage, isFetching, } = useGetRootSidebarPagesQuery({ spaceId, }); const [, setTreeApi] = useAtom>(treeApiAtom); const treeApiRef = useRef>(); const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const rootElement = useRef(); const { ref: sizeRef, width, height } = useElementSize(); const mergedRef = useMergedRef(rootElement, sizeRef); const [isDataLoaded, setIsDataLoaded] = useState(false); const { data: currentPage } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); useEffect(() => { if (hasNextPage && !isFetching) { fetchNextPage(); } }, [hasNextPage, fetchNextPage, isFetching, spaceId]); useEffect(() => { if (pagesData?.pages && !hasNextPage) { const allItems = pagesData.pages.flatMap((page) => page.items); const treeData = buildTree(allItems); if (data.length < 1 || data?.[0].spaceId !== spaceId) { //Thoughts // don't reset if there is data in state // we only expect to call this once on initial load // even if we decide to refetch, it should only update // and append root pages instead of resetting the entire tree // which looses async loaded children too setData(treeData); setIsDataLoaded(true); setOpenTreeNodes({}); } } }, [pagesData, hasNextPage]); useEffect(() => { const fetchData = async () => { if (isDataLoaded && currentPage) { // check if pageId node is present in the tree const node = dfs(treeApiRef.current?.root, currentPage.id); if (node) { // if node is found, no need to traverse its ancestors return; } // if not found, fetch and build its ancestors and their children if (!currentPage.id) return; const ancestors = await getPageBreadcrumbs(currentPage.id); if (ancestors && ancestors?.length > 1) { let flatTreeItems = [...buildTree(ancestors)]; const fetchAndUpdateChildren = async (ancestor: IPage) => { // we don't want to fetch the children of the opened page if (ancestor.id === currentPage.id) { return; } const children = await fetchAncestorChildren({ pageId: ancestor.id, spaceId: ancestor.spaceId, }); flatTreeItems = [ ...flatTreeItems, ...children.filter( (child) => !flatTreeItems.some((item) => item.id === child.id), ), ]; }; const fetchPromises = ancestors.map((ancestor) => fetchAndUpdateChildren(ancestor), ); // Wait for all fetch operations to complete Promise.all(fetchPromises).then(() => { // build tree with children const ancestorsTree = buildTreeWithChildren(flatTreeItems); // child of root page we're attaching the built ancestors to const rootChild = ancestorsTree[0]; // attach built ancestors to tree const updatedTree = appendNodeChildren( data, rootChild.id, rootChild.children, ); setData(updatedTree); setTimeout(() => { // focus on node and open all parents treeApiRef.current.select(currentPage.id); }, 100); }); } } }; fetchData(); }, [isDataLoaded, currentPage?.id]); useEffect(() => { if (currentPage?.id) { setTimeout(() => { // focus on node and open all parents treeApiRef.current?.select(currentPage.id, { align: "auto" }); }, 200); } else { treeApiRef.current?.deselectAll(); } }, [currentPage?.id]); useEffect(() => { if (treeApiRef.current) { // @ts-ignore setTreeApi(treeApiRef.current); } }, [treeApiRef.current]); return (
{rootElement.current && ( node?.spaceId === spaceId)} disableDrag={readOnly} disableDrop={readOnly} disableEdit={readOnly} {...controllers} width={width} height={rootElement.current.clientHeight} ref={treeApiRef} openByDefault={false} disableMultiSelection={true} className={classes.tree} rowClassName={classes.row} rowHeight={30} overscanCount={10} dndRootElement={rootElement.current} onToggle={() => { setOpenTreeNodes(treeApiRef.current?.openState); }} initialOpenState={openTreeNodes} > {Node} )}
); } function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const { t } = useTranslation(); const updatePageMutation = useUpdatePageMutation(); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); const { spaceSlug } = useParams(); const timerRef = useRef(null); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const prefetchPage = () => { timerRef.current = setTimeout(() => { queryClient.prefetchQuery({ queryKey: ["pages", node.data.slugId], queryFn: () => getPageById({ pageId: node.data.slugId }), staleTime: 5 * 60 * 1000, }); }, 150); }; const cancelPagePrefetch = () => { if (timerRef.current) { window.clearTimeout(timerRef.current); timerRef.current = null; } }; async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; if (node.data.children && node.data.children.length > 0) { return; } try { const params: SidebarPagesParams = { pageId: node.data.id, spaceId: node.data.spaceId, }; const newChildren = await queryClient.fetchQuery({ queryKey: ["sidebar-pages", params], queryFn: () => getSidebarPages(params), staleTime: 10 * 60 * 1000, }); const childrenTree = buildTree(newChildren.items); const updatedTreeData = appendNodeChildren( treeData, node.data.id, childrenTree, ); setTreeData(updatedTreeData); } catch (error) { console.error("Failed to fetch children:", error); } } const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon); setTreeData(updatedTree); }; const handleEmojiIconClick = (e: any) => { e.preventDefault(); e.stopPropagation(); }; const handleEmojiSelect = (emoji: { native: string }) => { handleUpdateNodeIcon(node.id, emoji.native); updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native }); setTimeout(() => { emit({ operation: "updateOne", spaceId: node.data.spaceId, entity: ["pages"], id: node.id, payload: { icon: emoji.native }, }); }, 50); }; const handleRemoveEmoji = () => { handleUpdateNodeIcon(node.id, null); updatePageMutation.mutateAsync({ pageId: node.id, icon: null }); setTimeout(() => { emit({ operation: "updateOne", spaceId: node.data.spaceId, entity: ["pages"], id: node.id, payload: { icon: null }, }); }, 50); }; if ( node.willReceiveDrop && node.isClosed && (node.children.length > 0 || node.data.hasChildren) ) { handleLoadChildren(node); setTimeout(() => { if (node.state.willReceiveDrop) { node.open(); } }, 650); } const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name); return ( <> { if (mobileSidebarOpened) { toggleMobileSidebar(); } }} onMouseEnter={prefetchPage} onMouseLeave={cancelPagePrefetch} > handleLoadChildren(node)} />
) } readOnly={tree.props.disableEdit as boolean} removeEmojiAction={handleRemoveEmoji} />
{node.data.name || t("untitled")}
{!tree.props.disableEdit && ( handleLoadChildren(node)} /> )}
); } interface CreateNodeProps { node: NodeApi; treeApi: TreeApi; onExpandTree?: () => void; } function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { function handleCreate() { if (node.data.hasChildren && node.children.length === 0) { node.toggle(); onExpandTree(); setTimeout(() => { treeApi?.create({ type: "internal", parentId: node.id, index: 0 }); }, 500); } else { treeApi?.create({ type: "internal", parentId: node.id }); } } return ( { e.preventDefault(); e.stopPropagation(); handleCreate(); }} > ); } interface NodeMenuProps { node: NodeApi; treeApi: TreeApi; } function NodeMenu({ node, treeApi }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); const { openDeleteModal } = useDeletePageModal(); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ movePageModalOpened, { open: openMovePageModal, close: closeMoveSpaceModal }, ] = useDisclosure(false); const [ copyPageModalOpened, { open: openCopyPageModal, close: closeCopySpaceModal }, ] = useDisclosure(false); const handleCopyLink = () => { const pageUrl = getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name); clipboard.copy(pageUrl); notifications.show({ message: t("Link copied") }); }; return ( <> { e.preventDefault(); e.stopPropagation(); }} > } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleCopyLink(); }} > {t("Copy link")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openExportModal(); }} > {t("Export page")} {!(treeApi.props.disableEdit as boolean) && ( <> } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openMovePageModal(); }} > {t("Move")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openCopyPageModal(); }} > {t("Copy")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openDeleteModal({ onConfirm: () => treeApi?.delete(node) }); }} > {t("Delete")} )} ); } interface PageArrowProps { node: NodeApi; onExpandTree?: () => void; } function PageArrow({ node, onExpandTree }: PageArrowProps) { return ( { e.preventDefault(); e.stopPropagation(); node.toggle(); onExpandTree(); }} > {node.isInternal ? ( node.children && (node.children.length > 0 || node.data.hasChildren) ? ( node.isOpen ? ( ) : ( ) ) : ( ) ) : null} ); }