SimplifiedItemsTree.tsx 7.1 KB

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