import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react"; import { ChevronRight, FileText } from "lucide-react"; import { Skeleton } from "@/client/components/ui/skeleton"; import { api } from "@/client/lib/api"; import type { Page } from "@/shared/types"; import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; import { EmojiIcon } from "@/client/components/ui/emoji-icon"; import type { ShareRootPage } from "@/client/lib/share-page-model"; import { getSidebarTreeChevronLeft, getSidebarTreeContentPaddingLeft, getSidebarTreeStandalonePaddingLeft, } from "./tree-metrics"; interface TreeNodeData { page: Page; children: TreeNodeData[] | null; // null = not loaded expanded: boolean; } interface TreeState { pagesById: Map; childrenByParentId: Map; expandedIds: Set; } type TreeAction = | { type: "children-loaded"; parentId: string; children: Page[] } | { type: "expand"; pageId: string } | { type: "toggle"; pageId: string }; function createTreeState(): TreeState { return { pagesById: new Map(), childrenByParentId: new Map(), expandedIds: new Set(), }; } function treeReducer(state: TreeState, action: TreeAction): TreeState { switch (action.type) { case "children-loaded": { const pagesById = new Map(state.pagesById); const childrenByParentId = new Map(state.childrenByParentId); for (const child of action.children) { pagesById.set(child.id, child); } childrenByParentId.set( action.parentId, action.children.map((child) => child.id), ); return { ...state, pagesById, childrenByParentId }; } case "expand": { if (state.expandedIds.has(action.pageId)) return state; const expandedIds = new Set(state.expandedIds); expandedIds.add(action.pageId); return { ...state, expandedIds }; } case "toggle": { const expandedIds = new Set(state.expandedIds); if (expandedIds.has(action.pageId)) expandedIds.delete(action.pageId); else expandedIds.add(action.pageId); return { ...state, expandedIds }; } } } function isTreeNode(node: TreeNodeData | null): node is TreeNodeData { return node !== null; } function TreeNode({ node, depth, activePageId, onNavigate, onToggle, }: { node: TreeNodeData; depth: number; activePageId: string; onNavigate: (pageId: string) => void; onToggle: (pageId: string) => void; }) { const isActive = node.page.id === activePageId; const showChevron = node.children === null || node.children.length > 0; const rowPaddingLeft = getSidebarTreeContentPaddingLeft(depth); const chevronLeft = getSidebarTreeChevronLeft(depth); return (
{showChevron && ( )}
{node.expanded && (node.children === null ? (
) : ( node.children.map((child) => ( )) ))}
); } export function SharedPageTree({ workspaceId, rootPage, shareToken, activePageId, autoExpandPathIds, onNavigate, }: { workspaceId: string; rootPage: ShareRootPage; shareToken: string; activePageId: string; autoExpandPathIds: string[]; onNavigate: (pageId: string) => void; }) { return ( ); } function SharedPageTreeForScope({ workspaceId, rootPage, shareToken, activePageId, autoExpandPathIds, onNavigate, }: { workspaceId: string; rootPage: ShareRootPage; shareToken: string; activePageId: string; autoExpandPathIds: string[]; onNavigate: (pageId: string) => void; }) { const [treeState, dispatch] = useReducer(treeReducer, undefined, createTreeState); const lastAutoExpandPathKeyRef = useRef(null); const autoExpandPathKey = autoExpandPathIds.join("\u0000"); const rootChildIds = treeState.childrenByParentId.get(rootPage.id) ?? null; const nodesById = useMemo(() => { const map = new Map(); function toNode(pageId: string): TreeNodeData | null { const page = treeState.pagesById.get(pageId); if (!page) return null; const childIds = treeState.childrenByParentId.get(page.id) ?? null; let children: TreeNodeData[] | null = null; if (childIds) { children = childIds.map(toNode).filter(isTreeNode); } const expanded = treeState.expandedIds.has(page.id); const node = { page, children, expanded }; map.set(page.id, node); return node; } for (const childId of rootChildIds ?? []) { toNode(childId); } return map; }, [rootChildIds, treeState]); const loadChildren = useCallback( async (parentId: string) => { try { const children = await api.pages.children(workspaceId, parentId, shareToken); dispatch({ type: "children-loaded", parentId, children }); return children; } catch { return []; } }, [shareToken, workspaceId], ); const getLoadedChildIds = useEffectEvent((parentId: string) => treeState.childrenByParentId.get(parentId) ?? null); useEffect(() => { void loadChildren(rootPage.id); }, [loadChildren, rootPage.id]); useEffect(() => { if (rootChildIds === null) return; if (lastAutoExpandPathKeyRef.current === autoExpandPathKey) return; if (!autoExpandPathKey) { lastAutoExpandPathKeyRef.current = autoExpandPathKey; return; } let cancelled = false; async function expandActivePath() { let visibleChildIds: readonly string[] = rootChildIds ?? []; for (const pageId of autoExpandPathIds) { if (cancelled) return; if (!visibleChildIds.includes(pageId)) return; dispatch({ type: "expand", pageId }); let childIds = getLoadedChildIds(pageId); if (!childIds) { const children = await loadChildren(pageId); if (cancelled) return; childIds = children.map((child) => child.id); } visibleChildIds = childIds; } } void expandActivePath().finally(() => { if (!cancelled) { lastAutoExpandPathKeyRef.current = autoExpandPathKey; } }); return () => { cancelled = true; }; }, [autoExpandPathIds, autoExpandPathKey, loadChildren, rootChildIds]); const handleToggle = useCallback( (pageId: string) => { const childrenLoaded = treeState.childrenByParentId.has(pageId); dispatch({ type: "toggle", pageId }); if (!childrenLoaded) { void loadChildren(pageId); } }, [loadChildren, treeState.childrenByParentId], ); return ( ); }