import { NodeApi, NodeRendererProps, Tree, TreeApi, SimpleTree, } from "react-arborist"; import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { fetchAllAncestorChildren, 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, Text } from "@mantine/core"; import { IconArrowRight, IconChevronDown, IconChevronRight, IconCopy, IconDotsVertical, IconFileDescription, IconFileExport, IconLink, IconPlus, IconPointFilled, IconStar, IconStarFilled, IconTrash, } from "@tabler/icons-react"; import { appendNodeChildrenAtom, 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, mergeRootTrees, 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 { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard"; 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"; import { duplicatePage } from "../../services/page-service.ts"; import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query"; interface SpaceTreeProps { spaceId: string; readOnly: boolean; } const openTreeNodesAtom = atom({}); export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const { t } = useTranslation(); 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 [isRootReady, setIsRootReady] = useState(false); const { ref: sizeRef, width, height } = useElementSize(); const mergedRef = useMergedRef((element) => { rootElement.current = element; if (element && !isRootReady) { setIsRootReady(true); } }, sizeRef); const [isDataLoaded, setIsDataLoaded] = useState(false); const spaceIdRef = useRef(spaceId); spaceIdRef.current = spaceId; const { data: currentPage } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); useEffect(() => { setIsDataLoaded(false); }, [spaceId]); 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); setData((prev) => { // fresh space; full reset if (prev.length === 0 || prev[0]?.spaceId !== spaceId) { setIsDataLoaded(true); setOpenTreeNodes({}); return treeData; } // same space; append only missing roots setIsDataLoaded(true); return mergeRootTrees(prev, treeData); }); } }, [pagesData, hasNextPage, spaceId]); useEffect(() => { const effectSpaceId = spaceId; 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 (spaceIdRef.current !== effectSpaceId) return; 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 fetchAllAncestorChildren({ 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(() => { if (spaceIdRef.current !== effectSpaceId) return; // 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 using functional updater // to avoid stale closure overwriting the current tree data setData((currentData) => appendNodeChildren(currentData, rootChild.id, rootChild.children), ); 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]); // Clean up tree API on unmount useEffect(() => { return () => { // @ts-ignore setTreeApi(null); }; }, [setTreeApi]); const filteredData = data.filter((node) => node?.spaceId === spaceId); return (
{isDataLoaded && filteredData.length === 0 && ( {t("No pages yet")} )} {isRootReady && rootElement.current && ( { return data.canEdit === false; } } disableDrop={ readOnly ? true : ({ parentNode }) => parentNode?.data?.canEdit === false } disableEdit={readOnly ? true : (data) => data.canEdit === false} {...controllers} width={width} height={rootElement.current.clientHeight} ref={(ref) => { treeApiRef.current = ref; if (ref) { //@ts-ignore setTreeApi(ref); } }} 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 [, appendChildren] = useAtom(appendNodeChildrenAtom); const emit = useQueryEmit(); const { spaceSlug } = useParams(); const timerRef = useRef(null); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const prefetchPage = () => { timerRef.current = setTimeout(async () => { const page = await queryClient.fetchQuery({ queryKey: ["pages", node.data.id], queryFn: () => getPageById({ pageId: node.data.id }), staleTime: 5 * 60 * 1000, }); if (page?.slugId) { queryClient.setQueryData(["pages", page.slugId], page); } }, 150); }; const cancelPagePrefetch = () => { if (timerRef.current) { window.clearTimeout(timerRef.current); timerRef.current = null; } }; async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; // in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket // if (node.data.children && node.data.children.length > 0) { // return; // } try { const params: SidebarPagesParams = { pageId: node.data.id, spaceId: node.data.spaceId, }; const childrenTree = await fetchAllAncestorChildren(params); appendChildren({ parentId: node.data.id, children: childrenTree, }); } 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 }) .then((data) => { setTimeout(() => { emit({ operation: "updateOne", spaceId: node.data.spaceId, entity: ["pages"], id: node.id, payload: { icon: emoji.native, parentPageId: data.parentPageId }, }); }, 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 === true || node.data.canEdit === false } removeEmojiAction={handleRemoveEmoji} />
{node.data.name || t("untitled")}
{tree.props.disableEdit !== true && node.data.canEdit !== false && ( handleLoadChildren(node)} /> )}
); } interface CreateNodeProps { node: NodeApi; treeApi: TreeApi; onExpandTree?: () => void; } function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { const { t } = useTranslation(); 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; spaceId: string; } function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); const { openDeleteModal } = useDeletePageModal(); const [data, setData] = useAtom(treeDataAtom); const emit = useQueryEmit(); 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 favoriteIds = useFavoriteIds("page", spaceId); const addFavorite = useAddFavoriteMutation(); const removeFavorite = useRemoveFavoriteMutation(); const isFavorited = favoriteIds.has(node.data.id); const handleCopyLink = () => { const pageUrl = getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name); clipboard.copy(pageUrl); notifications.show({ message: t("Link copied") }); }; const handleDuplicatePage = async () => { try { const duplicatedPage = await duplicatePage({ pageId: node.id, }); // Find the index of the current node const parentId = node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__" ? null : node.parent?.id; const siblings = parentId ? node.parent.children : treeApi?.props.data; const currentIndex = siblings?.findIndex((sibling) => sibling.id === node.id) || 0; const newIndex = currentIndex + 1; // Add the duplicated page to the tree const treeNodeData: SpaceTreeNode = { id: duplicatedPage.id, slugId: duplicatedPage.slugId, name: duplicatedPage.title, position: duplicatedPage.position, spaceId: duplicatedPage.spaceId, parentPageId: duplicatedPage.parentPageId, icon: duplicatedPage.icon, hasChildren: duplicatedPage.hasChildren, canEdit: true, children: [], }; // Update local tree const simpleTree = new SimpleTree(data); simpleTree.create({ parentId, index: newIndex, data: treeNodeData, }); setData(simpleTree.data); // Emit socket event setTimeout(() => { emit({ operation: "addTreeNode", spaceId: spaceId, payload: { parentId, index: newIndex, data: treeNodeData, }, }); }, 50); notifications.show({ message: t("Page duplicated successfully"), }); } catch (err) { notifications.show({ message: err.response?.data.message || "An error occurred", color: "red", }); } }; return ( <> { e.preventDefault(); e.stopPropagation(); }} > } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleCopyLink(); }} > {t("Copy link")} : } onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (isFavorited) { removeFavorite.mutate({ type: "page", pageId: node.data.id }); } else { addFavorite.mutate({ type: "page", pageId: node.data.id }); } }} > {isFavorited ? t("Remove from favorites") : t("Add to favorites")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openExportModal(); }} > {t("Export page")} {treeApi.props.disableEdit !== true && node.data.canEdit !== false && ( <> } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDuplicatePage(); }} > {t("Duplicate")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openMovePageModal(); }} > {t("Move")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openCopyPageModal(); }} > {t("Copy to space")} } onClick={(e) => { e.preventDefault(); e.stopPropagation(); openDeleteModal({ onConfirm: () => treeApi?.delete(node) }); }} > {t("Move to trash")} )} ); } interface PageArrowProps { node: NodeApi; onExpandTree?: () => void; } function PageArrow({ node, onExpandTree }: PageArrowProps) { const { t } = useTranslation(); useEffect(() => { if (node.isOpen) { onExpandTree(); } }, []); return ( { e.preventDefault(); e.stopPropagation(); node.toggle(); onExpandTree(); }} > {node.isInternal ? ( node.children && (node.children.length > 0 || node.data.hasChildren) ? ( node.isOpen ? ( ) : ( ) ) : ( ) ) : null} ); }