SimplifiedItemsTree.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import type { FC } from 'react';
  2. import { useCallback, 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 {
  19. usePageTreeInformationGeneration,
  20. usePageTreeRevalidationEffect,
  21. } from '../states/page-tree-update';
  22. type Props = {
  23. targetPath: string;
  24. targetPathOrId?: string;
  25. isWipPageShown?: boolean;
  26. isEnableActions?: boolean;
  27. isReadOnlyUser?: boolean;
  28. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  29. estimateTreeItemSize: () => number;
  30. scrollerElem?: HTMLElement | null;
  31. };
  32. export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
  33. const {
  34. targetPath,
  35. targetPathOrId,
  36. isWipPageShown = true,
  37. isEnableActions = false,
  38. isReadOnlyUser = false,
  39. CustomTreeItem,
  40. estimateTreeItemSize,
  41. scrollerElem,
  42. } = props;
  43. const [, setRebuildTrigger] = useState(0);
  44. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  45. const rootPage = rootPageResult?.rootPage;
  46. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  47. const allPagesCount = rootPage?.descendantCount ?? 0;
  48. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  49. // Page rename hook
  50. const { rename, getPageName } = usePageRename();
  51. // onRename handler for headless-tree
  52. const handleRename = useCallback(
  53. async (item, newValue: string) => {
  54. await rename(item, newValue);
  55. // Trigger re-render after rename
  56. setRebuildTrigger((prev) => prev + 1);
  57. },
  58. [rename],
  59. );
  60. const tree = useTree<IPageForTreeItem>({
  61. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  62. getItemName: (item) => getPageName(item),
  63. initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
  64. isItemFolder: (item) => item.getItemData().descendantCount > 0,
  65. createLoadingItemData: () => ({
  66. _id: '',
  67. path: 'Loading...',
  68. parent: '',
  69. descendantCount: 0,
  70. revision: '',
  71. grant: 1,
  72. isEmpty: false,
  73. wip: false,
  74. }),
  75. dataLoader,
  76. onRename: handleRename,
  77. features: [
  78. asyncDataLoaderFeature,
  79. selectionFeature,
  80. hotkeysCoreFeature,
  81. renamingFeature,
  82. ],
  83. });
  84. // Track local generation number
  85. const [localGeneration, setLocalGeneration] = useState(1);
  86. const globalGeneration = usePageTreeInformationGeneration();
  87. // Refetch data when global generation is updated
  88. usePageTreeRevalidationEffect(tree, localGeneration, {
  89. // Update local generation number after revalidation
  90. onRevalidated: () => setLocalGeneration(globalGeneration),
  91. });
  92. const items = tree.getItems();
  93. const virtualizer = useVirtualizer({
  94. count: items.length,
  95. getScrollElement: () => scrollerElem ?? null,
  96. estimateSize: estimateTreeItemSize,
  97. overscan: 5,
  98. });
  99. // Scroll to selected item on mount or when targetPathOrId changes
  100. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  101. return (
  102. <div {...tree.getContainerProps()} className="list-group">
  103. {virtualizer.getVirtualItems().map((virtualItem) => {
  104. const item = items[virtualItem.index];
  105. const itemData = item.getItemData();
  106. // Skip rendering virtual root
  107. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  108. return null;
  109. }
  110. // Skip rendering WIP pages if not shown
  111. if (!isWipPageShown && itemData.wip) {
  112. return null;
  113. }
  114. const props = item.getProps();
  115. return (
  116. <div
  117. key={virtualItem.key}
  118. data-index={virtualItem.index}
  119. ref={(node) => {
  120. virtualizer.measureElement(node);
  121. if (node && props.ref) {
  122. (props.ref as (node: HTMLElement) => void)(node);
  123. }
  124. }}
  125. >
  126. <CustomTreeItem
  127. item={item}
  128. targetPath={targetPath}
  129. targetPathOrId={targetPathOrId}
  130. isWipPageShown={isWipPageShown}
  131. isEnableActions={isEnableActions}
  132. isReadOnlyUser={isReadOnlyUser}
  133. onToggle={() => {
  134. // Trigger re-render to show/hide children
  135. setRebuildTrigger((prev) => prev + 1);
  136. }}
  137. />
  138. </div>
  139. );
  140. })}
  141. </div>
  142. );
  143. };