| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- import type { FC } from 'react';
- import { useCallback, useEffect, useMemo } from 'react';
- import { useTree } from '@headless-tree/react';
- import { useVirtualizer } from '@tanstack/react-virtual';
- import { useTranslation } from 'next-i18next';
- import { toastError, toastWarning } from '~/client/util/toastr';
- import type { IPageForTreeItem } from '~/interfaces/page';
- import { useSWRxRootPage } from '~/stores/page-listing';
- import { ROOT_PAGE_VIRTUAL_ID } from '../constants/_inner';
- import {
- useAutoExpandAncestors,
- useCheckboxChangeNotification,
- useCheckboxState,
- useDataLoader,
- useExpandParentOnCreate,
- useScrollToSelectedItem,
- useTreeFeatures,
- useTreeItemHandlers,
- useTreeRevalidation,
- } from '../hooks/_inner';
- import { usePageDnd, useSetEnableDragAndDrop } from '../hooks/use-page-dnd';
- import type { TreeItemProps } from '../interfaces';
- import { useTriggerTreeRebuild } from '../states/_inner';
- // Stable createLoadingItemData function
- const createLoadingItemData = (): IPageForTreeItem => ({
- _id: '',
- path: 'Loading...',
- parent: '',
- descendantCount: 0,
- grant: 1,
- isEmpty: false,
- wip: false,
- });
- type Props = {
- targetPath: string;
- targetPathOrId?: string;
- isWipPageShown?: boolean;
- isEnableActions?: boolean;
- isReadOnlyUser?: boolean;
- CustomTreeItem: React.FunctionComponent<TreeItemProps>;
- estimateTreeItemSize: () => number;
- scrollerElem?: HTMLElement | null;
- // Feature options
- enableRenaming?: boolean;
- enableCheckboxes?: boolean;
- enableDragAndDrop?: boolean;
- initialCheckedItems?: string[];
- onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
- };
- export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
- const {
- targetPath,
- targetPathOrId,
- isWipPageShown = true,
- isEnableActions = false,
- isReadOnlyUser = false,
- CustomTreeItem,
- estimateTreeItemSize,
- scrollerElem,
- enableRenaming = false,
- enableCheckboxes = false,
- enableDragAndDrop = false,
- initialCheckedItems = [],
- onCheckedItemsChange,
- } = props;
- const { t } = useTranslation();
- const triggerTreeRebuild = useTriggerTreeRebuild();
- const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
- const rootPage = rootPageResult?.rootPage;
- const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
- const allPagesCount = rootPage?.descendantCount ?? 0;
- const dataLoader = useDataLoader(rootPageId, allPagesCount);
- // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
- const { getItemName, isItemFolder, handleRename, creatingParentId } =
- useTreeItemHandlers(triggerTreeRebuild);
- // Configure tree features based on options
- const features = useTreeFeatures({
- enableRenaming,
- enableCheckboxes,
- enableDragAndDrop,
- });
- // Page move (drag and drop) handlers
- const { canDrag, canDrop, onDrop, renderDragLine } = usePageDnd();
- const setEnableDragAndDrop = useSetEnableDragAndDrop();
- // Set enable state for D&D
- useEffect(() => {
- setEnableDragAndDrop(enableDragAndDrop);
- }, [enableDragAndDrop, setEnableDragAndDrop]);
- // Wrap onDrop to show toast notifications
- const handleDrop = useCallback(
- async (...args: Parameters<typeof onDrop>) => {
- const result = await onDrop(...args);
- if (!result.success) {
- if (result.errorType === 'operation_blocked') {
- toastWarning(t('page_tree.move_blocked'));
- } else {
- toastError(t('page_tree.move_failed'));
- }
- }
- },
- [onDrop, t],
- );
- // Manage checkbox state (must be called before useTree to get setCheckedItems)
- const { checkedItemIds, setCheckedItems } = useCheckboxState({
- enabled: enableCheckboxes,
- initialCheckedItems,
- });
- // Stable initial state
- // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
- const initialState = useMemo(
- () => ({
- expandedItems: [ROOT_PAGE_VIRTUAL_ID],
- ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
- }),
- [enableCheckboxes],
- );
- const tree = useTree<IPageForTreeItem>({
- rootItemId: ROOT_PAGE_VIRTUAL_ID,
- getItemName,
- initialState,
- isItemFolder,
- createLoadingItemData,
- dataLoader,
- onRename: handleRename,
- features,
- // Checkbox configuration
- canCheckFolders: enableCheckboxes,
- propagateCheckedState: false,
- setCheckedItems,
- // Drag and drop configuration (only when enabled)
- ...(enableDragAndDrop && {
- canDrag,
- canDrop,
- onDrop: handleDrop,
- canDropInbetween: false, // No reordering, only drop as child
- }),
- });
- // Notify parent when checked items change
- useCheckboxChangeNotification({
- enabled: enableCheckboxes,
- checkedItemIds,
- tree,
- onCheckedItemsChange,
- });
- // Handle tree revalidation and items count tracking
- useTreeRevalidation({ tree, triggerTreeRebuild });
- // Expand parent item when page creation is initiated
- useExpandParentOnCreate({
- tree,
- creatingParentId,
- onTreeUpdated: triggerTreeRebuild,
- });
- const items = tree.getItems();
- // Auto-expand items that are ancestors of targetPath
- useAutoExpandAncestors({
- items,
- targetPath,
- onExpanded: triggerTreeRebuild,
- });
- const virtualizer = useVirtualizer({
- count: items.length,
- getScrollElement: () => scrollerElem ?? null,
- estimateSize: estimateTreeItemSize,
- overscan: 5,
- });
- // Scroll to selected item on mount or when targetPathOrId changes
- useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
- return (
- <div {...tree.getContainerProps()} className="list-group">
- {virtualizer.getVirtualItems().map((virtualItem) => {
- const item = items[virtualItem.index];
- const itemData = item.getItemData();
- // Skip rendering virtual root
- if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
- return null;
- }
- // Skip rendering WIP pages if not shown
- if (!isWipPageShown && itemData.wip) {
- return null;
- }
- const { ref: itemRef, ...itemProps } = item.getProps();
- // Exclude onClick from itemProps to prevent conflicts
- const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
- return (
- <div
- key={virtualItem.key}
- data-index={virtualItem.index}
- ref={(node) => {
- virtualizer.measureElement(node);
- if (node && itemRef) {
- (itemRef as (node: HTMLElement) => void)(node);
- }
- }}
- // Apply props
- {...itemPropsWithoutOnClick}
- >
- <CustomTreeItem
- item={item}
- targetPath={targetPath}
- targetPathOrId={targetPathOrId}
- isWipPageShown={isWipPageShown}
- isEnableActions={isEnableActions}
- isReadOnlyUser={isReadOnlyUser}
- onToggle={triggerTreeRebuild}
- />
- </div>
- );
- })}
- {/* Drag line indicator (rendered by usePageDnd when D&D is enabled) */}
- {renderDragLine(tree)}
- </div>
- );
- };
|