| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- 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<TreeItemProps>;
- estimateTreeItemSize: () => number;
- scrollerElem?: HTMLElement | null;
- };
- export const SimplifiedItemsTree: FC<Props> = (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<IPageForTreeItem>({
- 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<string | null>(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 (
- <div {...tree.getContainerProps()} className="list-group">
- {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 (
- <div
- key={virtualItem.key}
- data-index={virtualItem.index}
- ref={(node) => {
- virtualizer.measureElement(node);
- if (node && props.ref) {
- (props.ref as (node: HTMLElement) => void)(node);
- }
- }}
- >
- <CustomTreeItem
- item={item}
- targetPath={targetPath}
- targetPathOrId={targetPathOrId}
- isWipPageShown={isWipPageShown}
- isEnableActions={isEnableActions}
- isReadOnlyUser={isReadOnlyUser}
- onToggle={() => {
- // Trigger re-render to show/hide children
- setRebuildTrigger((prev) => prev + 1);
- }}
- />
- </div>
- );
- })}
- </div>
- );
- };
|