import type { FC } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { addTrailingSlash } from '@growi/core/dist/utils/path-utils'; import { asyncDataLoaderFeature, hotkeysCoreFeature, renamingFeature, selectionFeature, } from '@headless-tree/core'; import { useTree } from '@headless-tree/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { IPageForTreeItem } from '~/interfaces/page'; import { useSWRxRootPage } from '~/stores/page-listing'; import { ROOT_PAGE_VIRTUAL_ID } from '../../constants'; import { useDataLoader } from '../hooks/use-data-loader'; import { usePageCreate } from '../hooks/use-page-create'; import { usePageRename } from '../hooks/use-page-rename'; import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item'; import type { TreeItemProps } from '../interfaces'; import { useCreatingParentId } from '../states/page-tree-create'; import { usePageTreeInformationGeneration, usePageTreeRevalidationEffect, } from '../states/page-tree-update'; type Props = { targetPath: string; targetPathOrId?: string; isWipPageShown?: boolean; isEnableActions?: boolean; isReadOnlyUser?: boolean; CustomTreeItem: React.FunctionComponent; estimateTreeItemSize: () => number; scrollerElem?: HTMLElement | null; }; export const SimplifiedItemsTree: FC = (props: Props) => { const { targetPath, targetPathOrId, isWipPageShown = true, isEnableActions = false, isReadOnlyUser = false, CustomTreeItem, estimateTreeItemSize, scrollerElem, } = props; const [, setRebuildTrigger] = useState(0); const { data: rootPageResult } = useSWRxRootPage({ suspense: true }); const rootPage = rootPageResult?.rootPage; const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID; const allPagesCount = rootPage?.descendantCount ?? 0; const dataLoader = useDataLoader(rootPageId, allPagesCount); // Page rename hook const { rename, getPageName } = usePageRename(); // Page create hook const { createFromPlaceholder, isCreatingPlaceholder, cancelCreating } = usePageCreate(); // Get creating parent id to determine if item should be treated as folder const creatingParentId = useCreatingParentId(); // onRename handler for headless-tree // Handles both rename and create (for placeholder nodes) const handleRename = useCallback( async (item, newValue: string) => { if (isCreatingPlaceholder(item)) { // Placeholder node: create new page or cancel if empty if (newValue.trim() === '') { // Empty value means cancel (Esc key or blur) cancelCreating(); } else { await createFromPlaceholder(item, newValue); } } else { // Normal node: rename page await rename(item, newValue); } // Trigger re-render after operation setRebuildTrigger((prev) => prev + 1); }, [rename, createFromPlaceholder, isCreatingPlaceholder, cancelCreating], ); const tree = useTree({ rootItemId: ROOT_PAGE_VIRTUAL_ID, getItemName: (item) => getPageName(item), initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] }, // Item is a folder if it has loaded children OR if it's currently in "creating" mode // Use getChildren() to check actual cached children instead of descendantCount isItemFolder: (item) => { const itemData = item.getItemData(); const isCreatingUnderThis = creatingParentId === itemData._id; if (isCreatingUnderThis) return true; // Check cached children - getChildren() returns cached child items const children = item.getChildren(); if (children.length > 0) return true; // Fallback to descendantCount for items not yet expanded return itemData.descendantCount > 0; }, createLoadingItemData: () => ({ _id: '', path: 'Loading...', parent: '', descendantCount: 0, revision: '', grant: 1, isEmpty: false, wip: false, }), dataLoader, onRename: handleRename, features: [ asyncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, renamingFeature, ], }); // Track local generation number const [localGeneration, setLocalGeneration] = useState(1); const globalGeneration = usePageTreeInformationGeneration(); // Refetch data when global generation is updated usePageTreeRevalidationEffect(tree, localGeneration, { // Update local generation number after revalidation onRevalidated: () => setLocalGeneration(globalGeneration), }); // Expand and rebuild tree when creatingParentId changes useEffect(() => { if (creatingParentId == null) return; const { getItemInstance, rebuildTree } = tree; // Rebuild tree first to re-evaluate isItemFolder rebuildTree(); // Then expand the parent item const parentItem = getItemInstance(creatingParentId); if (parentItem != null && !parentItem.isExpanded()) { parentItem.expand(); } // Invalidate children to load placeholder parentItem?.invalidateChildrenIds(true); // Trigger re-render setRebuildTrigger((prev) => prev + 1); }, [creatingParentId, tree]); const items = tree.getItems(); // Track items count to detect when async data loading completes const prevItemsCountRef = useRef(items.length); useEffect(() => { if (items.length !== prevItemsCountRef.current) { prevItemsCountRef.current = items.length; // Trigger re-render when items count changes (e.g., after async load completes) setRebuildTrigger((prev) => prev + 1); } }, [items.length]); // Auto-expand items that are ancestors of targetPath // This runs at the parent level to handle all items regardless of virtualization const expandedForTargetPathRef = useRef(null); useEffect(() => { // Skip if no items loaded yet if (items.length === 0) { return; } // Skip if already fully processed for this targetPath if (expandedForTargetPathRef.current === targetPath) { return; } let didExpand = false; for (const item of items) { const itemData = item.getItemData(); const itemPath = itemData.path; if (itemPath == null) continue; // Check if this item is an ancestor of targetPath const isAncestorOfTarget = itemPath === '/' || (targetPath.startsWith(addTrailingSlash(itemPath)) && targetPath !== itemPath); if (!isAncestorOfTarget) continue; const isFolder = item.isFolder(); const isExpanded = item.isExpanded(); if (isFolder && !isExpanded) { item.expand(); didExpand = true; } } // If we expanded any items, trigger re-render to load children if (didExpand) { setRebuildTrigger((prev) => prev + 1); } else { // Only mark as fully processed when all ancestors are expanded // Check if we have all the ancestors we need const targetSegments = targetPath.split('/').filter(Boolean); let hasAllAncestors = true; // Build ancestor paths and check if they exist in items for (let i = 0; i < targetSegments.length - 1; i++) { const ancestorPath = '/' + targetSegments.slice(0, i + 1).join('/'); const ancestorItem = items.find(item => item.getItemData().path === ancestorPath); if (!ancestorItem) { hasAllAncestors = false; break; } } if (hasAllAncestors) { expandedForTargetPathRef.current = targetPath; } } }, [items, targetPath]); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollerElem ?? null, estimateSize: estimateTreeItemSize, overscan: 5, }); // Scroll to selected item on mount or when targetPathOrId changes useScrollToSelectedItem({ targetPathOrId, items, virtualizer }); return (
{virtualizer.getVirtualItems().map((virtualItem) => { const item = items[virtualItem.index]; const itemData = item.getItemData(); // Skip rendering virtual root if (itemData._id === ROOT_PAGE_VIRTUAL_ID) { return null; } // Skip rendering WIP pages if not shown if (!isWipPageShown && itemData.wip) { return null; } const props = item.getProps(); return (
{ virtualizer.measureElement(node); if (node && props.ref) { (props.ref as (node: HTMLElement) => void)(node); } }} > { // Trigger re-render to show/hide children setRebuildTrigger((prev) => prev + 1); }} />
); })}
); };