ItemsTree.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import type { FC } from 'react';
  2. import { useCallback, useEffect, useMemo } from 'react';
  3. import { useTree } from '@headless-tree/react';
  4. import { useVirtualizer } from '@tanstack/react-virtual';
  5. import { useTranslation } from 'next-i18next';
  6. import { toastError, toastWarning } from '~/client/util/toastr';
  7. import type { IPageForTreeItem } from '~/interfaces/page';
  8. import { useSWRxRootPage } from '~/stores/page-listing';
  9. import { ROOT_PAGE_VIRTUAL_ID } from '../constants/_inner';
  10. import {
  11. useAutoExpandAncestors,
  12. useDataLoader,
  13. useExpandParentOnCreate,
  14. useScrollToSelectedItem,
  15. useTreeFeatures,
  16. useTreeItemHandlers,
  17. useTreeRevalidation,
  18. } from '../hooks/_inner';
  19. import { useSocketUpdateDescCount } from '../hooks/use-socket-update-desc-count';
  20. import type { TreeItemProps } from '../interfaces';
  21. import { useTriggerTreeRebuild } from '../states/_inner';
  22. // Stable createLoadingItemData function
  23. const createLoadingItemData = (): IPageForTreeItem => ({
  24. _id: '',
  25. path: 'Loading...',
  26. parent: '',
  27. descendantCount: 0,
  28. grant: 1,
  29. isEmpty: false,
  30. wip: false,
  31. });
  32. type Props = {
  33. targetPath: string;
  34. targetPathOrId?: string;
  35. isWipPageShown?: boolean;
  36. isEnableActions?: boolean;
  37. isReadOnlyUser?: boolean;
  38. CustomTreeItem: React.FunctionComponent<TreeItemProps>;
  39. estimateTreeItemSize: () => number;
  40. scrollerElem?: HTMLElement | null;
  41. // Feature options
  42. enableRenaming?: boolean;
  43. enableCheckboxes?: boolean;
  44. enableDragAndDrop?: boolean;
  45. initialCheckedItems?: string[];
  46. onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
  47. };
  48. export const ItemsTree: FC<Props> = (props: Props) => {
  49. const {
  50. targetPath,
  51. targetPathOrId,
  52. isWipPageShown = true,
  53. isEnableActions = false,
  54. isReadOnlyUser = false,
  55. CustomTreeItem,
  56. estimateTreeItemSize,
  57. scrollerElem,
  58. enableRenaming = false,
  59. enableCheckboxes = false,
  60. enableDragAndDrop = false,
  61. initialCheckedItems = [],
  62. onCheckedItemsChange,
  63. } = props;
  64. const { t } = useTranslation();
  65. const triggerTreeRebuild = useTriggerTreeRebuild();
  66. const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
  67. const rootPage = rootPageResult?.rootPage;
  68. const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
  69. const allPagesCount = rootPage?.descendantCount ?? 0;
  70. const dataLoader = useDataLoader(rootPageId, allPagesCount);
  71. // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
  72. const { getItemName, isItemFolder, handleRename, creatingParentId, completeRenamingHotkey } =
  73. useTreeItemHandlers(triggerTreeRebuild);
  74. // Configure tree features and get checkbox state and D&D handlers
  75. const { features, checkboxProperties, dndProperties } = useTreeFeatures({
  76. enableRenaming,
  77. enableCheckboxes,
  78. enableDragAndDrop,
  79. initialCheckedItems,
  80. });
  81. const { setCheckedItems, createNotifyEffect } = checkboxProperties;
  82. const { canDrag, canDrop, onDrop, renderDragLine } = dndProperties;
  83. // Wrap onDrop to show toast notifications
  84. const handleDrop = useCallback(
  85. async (...args: Parameters<typeof onDrop>) => {
  86. const result = await onDrop(...args);
  87. if (!result.success) {
  88. if (result.errorType === 'operation_blocked') {
  89. toastWarning(t('page_tree.move_blocked'));
  90. } else {
  91. toastError(t('page_tree.move_failed'));
  92. }
  93. }
  94. },
  95. [onDrop, t],
  96. );
  97. // Stable initial state
  98. // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
  99. const initialState = useMemo(
  100. () => ({
  101. expandedItems: [ROOT_PAGE_VIRTUAL_ID],
  102. ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
  103. }),
  104. [enableCheckboxes],
  105. );
  106. const tree = useTree<IPageForTreeItem>({
  107. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  108. getItemName,
  109. initialState,
  110. isItemFolder,
  111. createLoadingItemData,
  112. dataLoader,
  113. onRename: handleRename,
  114. features,
  115. // Checkbox configuration
  116. canCheckFolders: enableCheckboxes,
  117. propagateCheckedState: false,
  118. setCheckedItems,
  119. // Drag and drop configuration (only when enabled)
  120. ...(enableDragAndDrop && {
  121. canDrag,
  122. canDrop,
  123. onDrop: handleDrop,
  124. canDropInbetween: false,
  125. }),
  126. hotkeys: {
  127. completeRenaming: completeRenamingHotkey
  128. }
  129. });
  130. // Notify parent when checked items change
  131. // biome-ignore lint/correctness/useExhaustiveDependencies: createNotifyEffect already includes checkedItemIds in its closure
  132. useEffect(createNotifyEffect(tree, onCheckedItemsChange), [
  133. createNotifyEffect,
  134. tree,
  135. ]);
  136. // Subscribe to Socket.io UpdateDescCount events
  137. useSocketUpdateDescCount();
  138. // Handle tree revalidation and items count tracking
  139. useTreeRevalidation({ tree, triggerTreeRebuild });
  140. // Expand parent item when page creation is initiated
  141. useExpandParentOnCreate({
  142. tree,
  143. creatingParentId,
  144. onTreeUpdated: triggerTreeRebuild,
  145. });
  146. const items = tree.getItems();
  147. // Auto-expand items that are ancestors of targetPath
  148. useAutoExpandAncestors({
  149. items,
  150. targetPath,
  151. onExpanded: triggerTreeRebuild,
  152. });
  153. const getScrollElement = useCallback(
  154. () => scrollerElem ?? null,
  155. [scrollerElem],
  156. );
  157. const stableEstimateSize = useCallback(() => {
  158. return estimateTreeItemSize();
  159. }, [estimateTreeItemSize]);
  160. const measureElement = useCallback(
  161. (element: Element | null) => {
  162. // Return consistent height measurement
  163. return element?.getBoundingClientRect().height ?? stableEstimateSize();
  164. },
  165. [stableEstimateSize],
  166. );
  167. const virtualizer = useVirtualizer({
  168. count: items.length,
  169. getScrollElement,
  170. estimateSize: stableEstimateSize,
  171. overscan: 5,
  172. measureElement,
  173. });
  174. // Scroll to selected item on mount or when targetPathOrId changes
  175. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  176. return (
  177. <div
  178. {...tree.getContainerProps()}
  179. className="list-group position-relative"
  180. style={{ height: `${virtualizer.getTotalSize()}px` }}
  181. >
  182. {virtualizer.getVirtualItems().map((virtualItem) => {
  183. const item = items[virtualItem.index];
  184. const itemData = item.getItemData();
  185. // Skip rendering virtual root
  186. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  187. return null;
  188. }
  189. // Skip rendering WIP pages if not shown
  190. if (!isWipPageShown && itemData.wip) {
  191. return null;
  192. }
  193. const { ref: itemRef, ...itemProps } = item.getProps();
  194. // Exclude onClick from itemProps to prevent conflicts
  195. const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
  196. return (
  197. <div
  198. key={virtualItem.key}
  199. data-index={virtualItem.index}
  200. style={{
  201. position: 'absolute',
  202. top: 0,
  203. left: 0,
  204. width: '100%',
  205. transform: `translateY(${virtualItem.start}px)`,
  206. }}
  207. ref={(node) => {
  208. if (node && itemRef) {
  209. (itemRef as (node: HTMLElement) => void)(node);
  210. }
  211. }}
  212. // Apply props
  213. {...itemPropsWithoutOnClick}
  214. >
  215. <CustomTreeItem
  216. item={item}
  217. targetPath={targetPath}
  218. targetPathOrId={targetPathOrId}
  219. isWipPageShown={isWipPageShown}
  220. isEnableActions={isEnableActions}
  221. isReadOnlyUser={isReadOnlyUser}
  222. onToggle={triggerTreeRebuild}
  223. />
  224. </div>
  225. );
  226. })}
  227. {/* Drag line indicator (rendered by dndProperties when D&D is enabled) */}
  228. {renderDragLine(tree)}
  229. </div>
  230. );
  231. };