SimplifiedItemsTree.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import type { FC } from 'react';
  2. import { useCallback, useEffect, useState } from 'react';
  3. import {
  4. asyncDataLoaderFeature,
  5. hotkeysCoreFeature,
  6. renamingFeature,
  7. selectionFeature,
  8. } from '@headless-tree/core';
  9. import { useTree } from '@headless-tree/react';
  10. import { useVirtualizer } from '@tanstack/react-virtual';
  11. import type { IPageForTreeItem } from '~/interfaces/page';
  12. import { useSWRxRootPage } from '~/stores/page-listing';
  13. import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
  14. import { useDataLoader } from '../hooks/use-data-loader';
  15. import { usePageRename } from '../hooks/use-page-rename';
  16. import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
  17. import type { TreeItemProps } from '../interfaces';
  18. import { useCreatingParentId } from '../states/page-tree-create';
  19. import {
  20. usePageTreeInformationGeneration,
  21. usePageTreeRevalidationEffect,
  22. } from '../states/page-tree-update';
  23. type Props = {
  24. targetPath: string;
  25. targetPathOrId?: string;
  26. isWipPageShown?: boolean;
  27. isEnableActions?: boolean;
  28. isReadOnlyUser?: boolean;
  29. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  30. estimateTreeItemSize: () => number;
  31. scrollerElem?: HTMLElement | null;
  32. };
  33. export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
  34. const {
  35. targetPath,
  36. targetPathOrId,
  37. isWipPageShown = true,
  38. isEnableActions = false,
  39. isReadOnlyUser = false,
  40. CustomTreeItem,
  41. estimateTreeItemSize,
  42. scrollerElem,
  43. } = props;
  44. const [, setRebuildTrigger] = useState(0);
  45. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  46. const rootPage = rootPageResult?.rootPage;
  47. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  48. const allPagesCount = rootPage?.descendantCount ?? 0;
  49. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  50. // Page rename hook
  51. const { rename, getPageName } = usePageRename();
  52. // Get creating parent id to determine if item should be treated as folder
  53. const creatingParentId = useCreatingParentId();
  54. // onRename handler for headless-tree
  55. const handleRename = useCallback(
  56. async (item, newValue: string) => {
  57. await rename(item, newValue);
  58. // Trigger re-render after rename
  59. setRebuildTrigger((prev) => prev + 1);
  60. },
  61. [rename],
  62. );
  63. const tree = useTree<IPageForTreeItem>({
  64. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  65. getItemName: (item) => getPageName(item),
  66. initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
  67. // Item is a folder if it has loaded children OR if it's currently in "creating" mode
  68. // Use getChildren() to check actual cached children instead of descendantCount
  69. isItemFolder: (item) => {
  70. const itemData = item.getItemData();
  71. const isCreatingUnderThis = creatingParentId === itemData._id;
  72. if (isCreatingUnderThis) return true;
  73. // Check cached children - getChildren() returns cached child items
  74. const children = item.getChildren();
  75. if (children.length > 0) return true;
  76. // Fallback to descendantCount for items not yet expanded
  77. return itemData.descendantCount > 0;
  78. },
  79. createLoadingItemData: () => ({
  80. _id: '',
  81. path: 'Loading...',
  82. parent: '',
  83. descendantCount: 0,
  84. revision: '',
  85. grant: 1,
  86. isEmpty: false,
  87. wip: false,
  88. }),
  89. dataLoader,
  90. onRename: handleRename,
  91. features: [
  92. asyncDataLoaderFeature,
  93. selectionFeature,
  94. hotkeysCoreFeature,
  95. renamingFeature,
  96. ],
  97. });
  98. // Track local generation number
  99. const [localGeneration, setLocalGeneration] = useState(1);
  100. const globalGeneration = usePageTreeInformationGeneration();
  101. // Refetch data when global generation is updated
  102. usePageTreeRevalidationEffect(tree, localGeneration, {
  103. // Update local generation number after revalidation
  104. onRevalidated: () => setLocalGeneration(globalGeneration),
  105. });
  106. // Expand and rebuild tree when creatingParentId changes
  107. useEffect(() => {
  108. if (creatingParentId == null) return;
  109. const { getItemInstance, rebuildTree } = tree;
  110. // Rebuild tree first to re-evaluate isItemFolder
  111. rebuildTree();
  112. // Then expand the parent item
  113. const parentItem = getItemInstance(creatingParentId);
  114. if (parentItem != null && !parentItem.isExpanded()) {
  115. parentItem.expand();
  116. }
  117. // Invalidate children to load placeholder
  118. parentItem?.invalidateChildrenIds(true);
  119. // Trigger re-render
  120. setRebuildTrigger((prev) => prev + 1);
  121. }, [creatingParentId, tree]);
  122. const items = tree.getItems();
  123. const virtualizer = useVirtualizer({
  124. count: items.length,
  125. getScrollElement: () => scrollerElem ?? null,
  126. estimateSize: estimateTreeItemSize,
  127. overscan: 5,
  128. });
  129. // Scroll to selected item on mount or when targetPathOrId changes
  130. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  131. return (
  132. <div {...tree.getContainerProps()} className="list-group">
  133. {virtualizer.getVirtualItems().map((virtualItem) => {
  134. const item = items[virtualItem.index];
  135. const itemData = item.getItemData();
  136. // Skip rendering virtual root
  137. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  138. return null;
  139. }
  140. // Skip rendering WIP pages if not shown
  141. if (!isWipPageShown && itemData.wip) {
  142. return null;
  143. }
  144. const props = item.getProps();
  145. return (
  146. <div
  147. key={virtualItem.key}
  148. data-index={virtualItem.index}
  149. ref={(node) => {
  150. virtualizer.measureElement(node);
  151. if (node && props.ref) {
  152. (props.ref as (node: HTMLElement) => void)(node);
  153. }
  154. }}
  155. >
  156. <CustomTreeItem
  157. item={item}
  158. targetPath={targetPath}
  159. targetPathOrId={targetPathOrId}
  160. isWipPageShown={isWipPageShown}
  161. isEnableActions={isEnableActions}
  162. isReadOnlyUser={isReadOnlyUser}
  163. onToggle={() => {
  164. // Trigger re-render to show/hide children
  165. setRebuildTrigger((prev) => prev + 1);
  166. }}
  167. />
  168. </div>
  169. );
  170. })}
  171. </div>
  172. );
  173. };