SimplifiedItemsTree.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import type { FC } from 'react';
  2. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  3. import type { ItemInstance } from '@headless-tree/core';
  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 { useAutoExpandAncestors } from '../hooks/use-auto-expand-ancestors';
  16. import { clearChildrenCache, useDataLoader } from '../hooks/use-data-loader';
  17. import { usePageCreate } from '../hooks/use-page-create';
  18. import { usePageRename } from '../hooks/use-page-rename';
  19. import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
  20. import type { TreeItemProps } from '../interfaces';
  21. import { useCreatingParentId } from '../states/page-tree-create';
  22. import {
  23. usePageTreeInformationGeneration,
  24. usePageTreeRevalidationEffect,
  25. } from '../states/page-tree-update';
  26. // Stable features array to avoid recreating on every render
  27. const TREE_FEATURES = [
  28. asyncDataLoaderFeature,
  29. selectionFeature,
  30. hotkeysCoreFeature,
  31. renamingFeature,
  32. ];
  33. // Stable createLoadingItemData function
  34. const createLoadingItemData = (): IPageForTreeItem => ({
  35. _id: '',
  36. path: 'Loading...',
  37. parent: '',
  38. descendantCount: 0,
  39. grant: 1,
  40. isEmpty: false,
  41. wip: false,
  42. });
  43. type Props = {
  44. targetPath: string;
  45. targetPathOrId?: string;
  46. isWipPageShown?: boolean;
  47. isEnableActions?: boolean;
  48. isReadOnlyUser?: boolean;
  49. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  50. estimateTreeItemSize: () => number;
  51. scrollerElem?: HTMLElement | null;
  52. };
  53. export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
  54. const {
  55. targetPath,
  56. targetPathOrId,
  57. isWipPageShown = true,
  58. isEnableActions = false,
  59. isReadOnlyUser = false,
  60. CustomTreeItem,
  61. estimateTreeItemSize,
  62. scrollerElem,
  63. } = props;
  64. const [, setRebuildTrigger] = useState(0);
  65. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  66. const rootPage = rootPageResult?.rootPage;
  67. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  68. const allPagesCount = rootPage?.descendantCount ?? 0;
  69. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  70. // Page rename hook
  71. const { rename, getPageName } = usePageRename();
  72. // Page create hook
  73. const { createFromPlaceholder, isCreatingPlaceholder, cancelCreating } =
  74. usePageCreate();
  75. // Get creating parent id to determine if item should be treated as folder
  76. const creatingParentId = useCreatingParentId();
  77. // Use refs to stabilize callbacks passed to useTree
  78. // This prevents headless-tree from detecting config changes and refetching data
  79. const creatingParentIdRef = useRef(creatingParentId);
  80. creatingParentIdRef.current = creatingParentId;
  81. const getPageNameRef = useRef(getPageName);
  82. getPageNameRef.current = getPageName;
  83. const renameRef = useRef(rename);
  84. renameRef.current = rename;
  85. const createFromPlaceholderRef = useRef(createFromPlaceholder);
  86. createFromPlaceholderRef.current = createFromPlaceholder;
  87. const isCreatingPlaceholderRef = useRef(isCreatingPlaceholder);
  88. isCreatingPlaceholderRef.current = isCreatingPlaceholder;
  89. const cancelCreatingRef = useRef(cancelCreating);
  90. cancelCreatingRef.current = cancelCreating;
  91. // Stable getItemName callback - receives ItemInstance from headless-tree
  92. const getItemName = useCallback((item: ItemInstance<IPageForTreeItem>) => {
  93. return getPageNameRef.current(item);
  94. }, []);
  95. // Stable isItemFolder callback
  96. // IMPORTANT: Do NOT call item.getChildren() here as it triggers API calls for ALL visible items
  97. const isItemFolder = useCallback((item: ItemInstance<IPageForTreeItem>) => {
  98. const itemData = item.getItemData();
  99. const currentCreatingParentId = creatingParentIdRef.current;
  100. const isCreatingUnderThis = currentCreatingParentId === itemData._id;
  101. if (isCreatingUnderThis) return true;
  102. // Use descendantCount from the item data to determine if it's a folder
  103. // This avoids triggering getChildrenWithData API calls
  104. return itemData.descendantCount > 0;
  105. }, []);
  106. // Stable onRename handler for headless-tree
  107. // Handles both rename and create (for placeholder nodes)
  108. const handleRename = useCallback(
  109. async (item: ItemInstance<IPageForTreeItem>, newValue: string) => {
  110. if (isCreatingPlaceholderRef.current(item)) {
  111. // Placeholder node: create new page or cancel if empty
  112. if (newValue.trim() === '') {
  113. // Empty value means cancel (Esc key or blur)
  114. cancelCreatingRef.current();
  115. } else {
  116. await createFromPlaceholderRef.current(item, newValue);
  117. }
  118. } else {
  119. // Normal node: rename page
  120. await renameRef.current(item, newValue);
  121. }
  122. // Trigger re-render after operation
  123. setRebuildTrigger((prev) => prev + 1);
  124. },
  125. [],
  126. );
  127. // Stable initial state
  128. const initialState = useMemo(() => ({ expandedItems: [ROOT_PAGE_VIRTUAL_ID] }), []);
  129. const tree = useTree<IPageForTreeItem>({
  130. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  131. getItemName,
  132. initialState,
  133. isItemFolder,
  134. createLoadingItemData,
  135. dataLoader,
  136. onRename: handleRename,
  137. features: TREE_FEATURES,
  138. });
  139. // Track local generation number
  140. const [localGeneration, setLocalGeneration] = useState(1);
  141. const globalGeneration = usePageTreeInformationGeneration();
  142. // Refetch data when global generation is updated
  143. usePageTreeRevalidationEffect(tree, localGeneration, {
  144. // Update local generation number after revalidation
  145. onRevalidated: () => setLocalGeneration(globalGeneration),
  146. });
  147. // Expand and rebuild tree when creatingParentId changes
  148. useEffect(() => {
  149. if (creatingParentId == null) return;
  150. const { getItemInstance, rebuildTree } = tree;
  151. // Rebuild tree first to re-evaluate isItemFolder
  152. rebuildTree();
  153. // Then expand the parent item
  154. const parentItem = getItemInstance(creatingParentId);
  155. if (parentItem != null && !parentItem.isExpanded()) {
  156. parentItem.expand();
  157. }
  158. // Clear cache for this parent and invalidate children to load placeholder
  159. clearChildrenCache([creatingParentId]);
  160. parentItem?.invalidateChildrenIds(true);
  161. // Trigger re-render
  162. setRebuildTrigger((prev) => prev + 1);
  163. }, [creatingParentId, tree]);
  164. const items = tree.getItems();
  165. // Track items count to detect when async data loading completes
  166. const prevItemsCountRef = useRef(items.length);
  167. useEffect(() => {
  168. if (items.length !== prevItemsCountRef.current) {
  169. prevItemsCountRef.current = items.length;
  170. // Trigger re-render when items count changes (e.g., after async load completes)
  171. setRebuildTrigger((prev) => prev + 1);
  172. }
  173. }, [items.length]);
  174. // Auto-expand items that are ancestors of targetPath
  175. const handleAutoExpanded = useCallback(() => {
  176. setRebuildTrigger((prev) => prev + 1);
  177. }, []);
  178. useAutoExpandAncestors({
  179. items,
  180. targetPath,
  181. onExpanded: handleAutoExpanded,
  182. });
  183. const virtualizer = useVirtualizer({
  184. count: items.length,
  185. getScrollElement: () => scrollerElem ?? null,
  186. estimateSize: estimateTreeItemSize,
  187. overscan: 5,
  188. });
  189. // Scroll to selected item on mount or when targetPathOrId changes
  190. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  191. return (
  192. <div {...tree.getContainerProps()} className="list-group">
  193. {virtualizer.getVirtualItems().map((virtualItem) => {
  194. const item = items[virtualItem.index];
  195. const itemData = item.getItemData();
  196. // Skip rendering virtual root
  197. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  198. return null;
  199. }
  200. // Skip rendering WIP pages if not shown
  201. if (!isWipPageShown && itemData.wip) {
  202. return null;
  203. }
  204. const props = item.getProps();
  205. return (
  206. <div
  207. key={virtualItem.key}
  208. data-index={virtualItem.index}
  209. ref={(node) => {
  210. virtualizer.measureElement(node);
  211. if (node && props.ref) {
  212. (props.ref as (node: HTMLElement) => void)(node);
  213. }
  214. }}
  215. >
  216. <CustomTreeItem
  217. item={item}
  218. targetPath={targetPath}
  219. targetPathOrId={targetPathOrId}
  220. isWipPageShown={isWipPageShown}
  221. isEnableActions={isEnableActions}
  222. isReadOnlyUser={isReadOnlyUser}
  223. onToggle={() => {
  224. // Trigger re-render to show/hide children
  225. setRebuildTrigger((prev) => prev + 1);
  226. }}
  227. />
  228. </div>
  229. );
  230. })}
  231. </div>
  232. );
  233. };