SimplifiedItemsTree.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import type { FC } from 'react';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
  4. import {
  5. asyncDataLoaderFeature,
  6. hotkeysCoreFeature,
  7. renamingFeature,
  8. selectionFeature,
  9. } from '@headless-tree/core';
  10. import { useTree } from '@headless-tree/react';
  11. import { useVirtualizer } from '@tanstack/react-virtual';
  12. import type { IPageForTreeItem } from '~/interfaces/page';
  13. import { useSWRxRootPage } from '~/stores/page-listing';
  14. import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
  15. import { useDataLoader } from '../hooks/use-data-loader';
  16. import { usePageCreate } from '../hooks/use-page-create';
  17. import { usePageRename } from '../hooks/use-page-rename';
  18. import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
  19. import type { TreeItemProps } from '../interfaces';
  20. import { useCreatingParentId } from '../states/page-tree-create';
  21. import {
  22. usePageTreeInformationGeneration,
  23. usePageTreeRevalidationEffect,
  24. } from '../states/page-tree-update';
  25. type Props = {
  26. targetPath: string;
  27. targetPathOrId?: string;
  28. isWipPageShown?: boolean;
  29. isEnableActions?: boolean;
  30. isReadOnlyUser?: boolean;
  31. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  32. estimateTreeItemSize: () => number;
  33. scrollerElem?: HTMLElement | null;
  34. };
  35. export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
  36. const {
  37. targetPath,
  38. targetPathOrId,
  39. isWipPageShown = true,
  40. isEnableActions = false,
  41. isReadOnlyUser = false,
  42. CustomTreeItem,
  43. estimateTreeItemSize,
  44. scrollerElem,
  45. } = props;
  46. const [, setRebuildTrigger] = useState(0);
  47. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  48. const rootPage = rootPageResult?.rootPage;
  49. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  50. const allPagesCount = rootPage?.descendantCount ?? 0;
  51. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  52. // Page rename hook
  53. const { rename, getPageName } = usePageRename();
  54. // Page create hook
  55. const { createFromPlaceholder, isCreatingPlaceholder, cancelCreating } =
  56. usePageCreate();
  57. // Get creating parent id to determine if item should be treated as folder
  58. const creatingParentId = useCreatingParentId();
  59. // onRename handler for headless-tree
  60. // Handles both rename and create (for placeholder nodes)
  61. const handleRename = useCallback(
  62. async (item, newValue: string) => {
  63. if (isCreatingPlaceholder(item)) {
  64. // Placeholder node: create new page or cancel if empty
  65. if (newValue.trim() === '') {
  66. // Empty value means cancel (Esc key or blur)
  67. cancelCreating();
  68. } else {
  69. await createFromPlaceholder(item, newValue);
  70. }
  71. } else {
  72. // Normal node: rename page
  73. await rename(item, newValue);
  74. }
  75. // Trigger re-render after operation
  76. setRebuildTrigger((prev) => prev + 1);
  77. },
  78. [rename, createFromPlaceholder, isCreatingPlaceholder, cancelCreating],
  79. );
  80. const tree = useTree<IPageForTreeItem>({
  81. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  82. getItemName: (item) => getPageName(item),
  83. initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
  84. // Item is a folder if it has loaded children OR if it's currently in "creating" mode
  85. // Use getChildren() to check actual cached children instead of descendantCount
  86. isItemFolder: (item) => {
  87. const itemData = item.getItemData();
  88. const isCreatingUnderThis = creatingParentId === itemData._id;
  89. if (isCreatingUnderThis) return true;
  90. // Check cached children - getChildren() returns cached child items
  91. const children = item.getChildren();
  92. if (children.length > 0) return true;
  93. // Fallback to descendantCount for items not yet expanded
  94. return itemData.descendantCount > 0;
  95. },
  96. createLoadingItemData: () => ({
  97. _id: '',
  98. path: 'Loading...',
  99. parent: '',
  100. descendantCount: 0,
  101. revision: '',
  102. grant: 1,
  103. isEmpty: false,
  104. wip: false,
  105. }),
  106. dataLoader,
  107. onRename: handleRename,
  108. features: [
  109. asyncDataLoaderFeature,
  110. selectionFeature,
  111. hotkeysCoreFeature,
  112. renamingFeature,
  113. ],
  114. });
  115. // Track local generation number
  116. const [localGeneration, setLocalGeneration] = useState(1);
  117. const globalGeneration = usePageTreeInformationGeneration();
  118. // Refetch data when global generation is updated
  119. usePageTreeRevalidationEffect(tree, localGeneration, {
  120. // Update local generation number after revalidation
  121. onRevalidated: () => setLocalGeneration(globalGeneration),
  122. });
  123. // Expand and rebuild tree when creatingParentId changes
  124. useEffect(() => {
  125. if (creatingParentId == null) return;
  126. const { getItemInstance, rebuildTree } = tree;
  127. // Rebuild tree first to re-evaluate isItemFolder
  128. rebuildTree();
  129. // Then expand the parent item
  130. const parentItem = getItemInstance(creatingParentId);
  131. if (parentItem != null && !parentItem.isExpanded()) {
  132. parentItem.expand();
  133. }
  134. // Invalidate children to load placeholder
  135. parentItem?.invalidateChildrenIds(true);
  136. // Trigger re-render
  137. setRebuildTrigger((prev) => prev + 1);
  138. }, [creatingParentId, tree]);
  139. const items = tree.getItems();
  140. // Track items count to detect when async data loading completes
  141. const prevItemsCountRef = useRef(items.length);
  142. useEffect(() => {
  143. if (items.length !== prevItemsCountRef.current) {
  144. prevItemsCountRef.current = items.length;
  145. // Trigger re-render when items count changes (e.g., after async load completes)
  146. setRebuildTrigger((prev) => prev + 1);
  147. }
  148. }, [items.length]);
  149. // Auto-expand items that are ancestors of targetPath
  150. // This runs at the parent level to handle all items regardless of virtualization
  151. const expandedForTargetPathRef = useRef<string | null>(null);
  152. useEffect(() => {
  153. // Skip if no items loaded yet
  154. if (items.length === 0) {
  155. return;
  156. }
  157. // Skip if already fully processed for this targetPath
  158. if (expandedForTargetPathRef.current === targetPath) {
  159. return;
  160. }
  161. let didExpand = false;
  162. for (const item of items) {
  163. const itemData = item.getItemData();
  164. const itemPath = itemData.path;
  165. if (itemPath == null) continue;
  166. // Check if this item is an ancestor of targetPath
  167. const isAncestorOfTarget =
  168. itemPath === '/' ||
  169. (targetPath.startsWith(addTrailingSlash(itemPath)) &&
  170. targetPath !== itemPath);
  171. if (!isAncestorOfTarget) continue;
  172. const isFolder = item.isFolder();
  173. const isExpanded = item.isExpanded();
  174. if (isFolder && !isExpanded) {
  175. item.expand();
  176. didExpand = true;
  177. }
  178. }
  179. // If we expanded any items, trigger re-render to load children
  180. if (didExpand) {
  181. setRebuildTrigger((prev) => prev + 1);
  182. }
  183. else {
  184. // Only mark as fully processed when all ancestors are expanded
  185. // Check if we have all the ancestors we need
  186. const targetSegments = targetPath.split('/').filter(Boolean);
  187. let hasAllAncestors = true;
  188. // Build ancestor paths and check if they exist in items
  189. for (let i = 0; i < targetSegments.length - 1; i++) {
  190. const ancestorPath = '/' + targetSegments.slice(0, i + 1).join('/');
  191. const ancestorItem = items.find(item => item.getItemData().path === ancestorPath);
  192. if (!ancestorItem) {
  193. hasAllAncestors = false;
  194. break;
  195. }
  196. }
  197. if (hasAllAncestors) {
  198. expandedForTargetPathRef.current = targetPath;
  199. }
  200. }
  201. }, [items, targetPath]);
  202. const virtualizer = useVirtualizer({
  203. count: items.length,
  204. getScrollElement: () => scrollerElem ?? null,
  205. estimateSize: estimateTreeItemSize,
  206. overscan: 5,
  207. });
  208. // Scroll to selected item on mount or when targetPathOrId changes
  209. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  210. return (
  211. <div {...tree.getContainerProps()} className="list-group">
  212. {virtualizer.getVirtualItems().map((virtualItem) => {
  213. const item = items[virtualItem.index];
  214. const itemData = item.getItemData();
  215. // Skip rendering virtual root
  216. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  217. return null;
  218. }
  219. // Skip rendering WIP pages if not shown
  220. if (!isWipPageShown && itemData.wip) {
  221. return null;
  222. }
  223. const props = item.getProps();
  224. return (
  225. <div
  226. key={virtualItem.key}
  227. data-index={virtualItem.index}
  228. ref={(node) => {
  229. virtualizer.measureElement(node);
  230. if (node && props.ref) {
  231. (props.ref as (node: HTMLElement) => void)(node);
  232. }
  233. }}
  234. >
  235. <CustomTreeItem
  236. item={item}
  237. targetPath={targetPath}
  238. targetPathOrId={targetPathOrId}
  239. isWipPageShown={isWipPageShown}
  240. isEnableActions={isEnableActions}
  241. isReadOnlyUser={isReadOnlyUser}
  242. onToggle={() => {
  243. // Trigger re-render to show/hide children
  244. setRebuildTrigger((prev) => prev + 1);
  245. }}
  246. />
  247. </div>
  248. );
  249. })}
  250. </div>
  251. );
  252. };