ItemsTree.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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 {
  73. getItemName,
  74. isItemFolder,
  75. handleRename,
  76. creatingParentId,
  77. completeRenamingHotkey,
  78. } = useTreeItemHandlers(triggerTreeRebuild);
  79. // Configure tree features and get checkbox state and D&D handlers
  80. const { features, checkboxProperties, dndProperties } = useTreeFeatures({
  81. enableRenaming,
  82. enableCheckboxes,
  83. enableDragAndDrop,
  84. initialCheckedItems,
  85. });
  86. const { setCheckedItems, createNotifyEffect } = checkboxProperties;
  87. const { canDrag, canDrop, onDrop, renderDragLine } = dndProperties;
  88. // Wrap onDrop to show toast notifications
  89. const handleDrop = useCallback(
  90. async (...args: Parameters<typeof onDrop>) => {
  91. const result = await onDrop(...args);
  92. if (!result.success) {
  93. if (result.errorType === 'operation_blocked') {
  94. toastWarning(t('page_tree.move_blocked'));
  95. } else {
  96. toastError(t('page_tree.move_failed'));
  97. }
  98. }
  99. },
  100. [onDrop, t],
  101. );
  102. // Stable initial state
  103. // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
  104. const initialState = useMemo(
  105. () => ({
  106. expandedItems: [ROOT_PAGE_VIRTUAL_ID],
  107. ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
  108. }),
  109. [enableCheckboxes],
  110. );
  111. const tree = useTree<IPageForTreeItem>({
  112. rootItemId: ROOT_PAGE_VIRTUAL_ID,
  113. getItemName,
  114. initialState,
  115. isItemFolder,
  116. createLoadingItemData,
  117. dataLoader,
  118. onRename: handleRename,
  119. features,
  120. // Checkbox configuration
  121. canCheckFolders: enableCheckboxes,
  122. propagateCheckedState: false,
  123. setCheckedItems,
  124. // Drag and drop configuration (only when enabled)
  125. ...(enableDragAndDrop && {
  126. canDrag,
  127. canDrop,
  128. onDrop: handleDrop,
  129. canDropInbetween: false,
  130. }),
  131. hotkeys: {
  132. completeRenaming: completeRenamingHotkey,
  133. },
  134. });
  135. // Notify parent when checked items change
  136. // biome-ignore lint/correctness/useExhaustiveDependencies: createNotifyEffect already includes checkedItemIds in its closure
  137. useEffect(createNotifyEffect(tree, onCheckedItemsChange), [
  138. createNotifyEffect,
  139. tree,
  140. ]);
  141. // Subscribe to Socket.io UpdateDescCount events
  142. useSocketUpdateDescCount();
  143. // Handle tree revalidation and items count tracking
  144. useTreeRevalidation({ tree, triggerTreeRebuild });
  145. // Expand parent item when page creation is initiated
  146. useExpandParentOnCreate({
  147. tree,
  148. creatingParentId,
  149. onTreeUpdated: triggerTreeRebuild,
  150. });
  151. const items = tree.getItems();
  152. // Auto-expand items that are ancestors of targetPath
  153. useAutoExpandAncestors({
  154. items,
  155. targetPath,
  156. onExpanded: triggerTreeRebuild,
  157. });
  158. const getScrollElement = useCallback(
  159. () => scrollerElem ?? null,
  160. [scrollerElem],
  161. );
  162. const stableEstimateSize = useCallback(() => {
  163. return estimateTreeItemSize();
  164. }, [estimateTreeItemSize]);
  165. const measureElement = useCallback(
  166. (element: Element | null) => {
  167. // Return consistent height measurement
  168. return element?.getBoundingClientRect().height ?? stableEstimateSize();
  169. },
  170. [stableEstimateSize],
  171. );
  172. const virtualizer = useVirtualizer({
  173. count: items.length,
  174. getScrollElement,
  175. estimateSize: stableEstimateSize,
  176. overscan: 5,
  177. measureElement,
  178. });
  179. // Scroll to selected item on mount or when targetPathOrId changes
  180. useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
  181. return (
  182. <div
  183. {...tree.getContainerProps()}
  184. className="list-group position-relative"
  185. style={{ height: `${virtualizer.getTotalSize()}px` }}
  186. >
  187. {virtualizer.getVirtualItems().map((virtualItem) => {
  188. const item = items[virtualItem.index];
  189. const itemData = item.getItemData();
  190. // Skip rendering virtual root
  191. if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
  192. return null;
  193. }
  194. // Skip rendering WIP pages if not shown
  195. if (!isWipPageShown && itemData.wip) {
  196. return null;
  197. }
  198. const { ref: itemRef, ...itemProps } = item.getProps();
  199. // Exclude onClick from itemProps to prevent conflicts
  200. const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
  201. return (
  202. <div
  203. key={virtualItem.key}
  204. data-index={virtualItem.index}
  205. style={{
  206. position: 'absolute',
  207. top: 0,
  208. left: 0,
  209. width: '100%',
  210. transform: `translateY(${virtualItem.start}px)`,
  211. }}
  212. ref={(node) => {
  213. if (node && itemRef) {
  214. (itemRef as (node: HTMLElement) => void)(node);
  215. }
  216. }}
  217. // Apply props
  218. {...itemPropsWithoutOnClick}
  219. >
  220. <CustomTreeItem
  221. item={item}
  222. targetPath={targetPath}
  223. targetPathOrId={targetPathOrId}
  224. isWipPageShown={isWipPageShown}
  225. isEnableActions={isEnableActions}
  226. isReadOnlyUser={isReadOnlyUser}
  227. onToggle={triggerTreeRebuild}
  228. />
  229. </div>
  230. );
  231. })}
  232. {/* Drag line indicator (rendered by dndProperties when D&D is enabled) */}
  233. {renderDragLine(tree)}
  234. </div>
  235. );
  236. };