| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- 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,
- useDataLoader,
- useExpandParentOnCreate,
- useScrollToSelectedItem,
- useTreeFeatures,
- useTreeItemHandlers,
- useTreeRevalidation,
- } from '../hooks/_inner';
- import { useSocketUpdateDescCount } from '../hooks/use-socket-update-desc-count';
- 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 ItemsTree: 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, completeRenamingHotkey } =
- useTreeItemHandlers(triggerTreeRebuild);
- // Configure tree features and get checkbox state and D&D handlers
- const { features, checkboxProperties, dndProperties } = useTreeFeatures({
- enableRenaming,
- enableCheckboxes,
- enableDragAndDrop,
- initialCheckedItems,
- });
- const { setCheckedItems, createNotifyEffect } = checkboxProperties;
- const { canDrag, canDrop, onDrop, renderDragLine } = dndProperties;
- // 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],
- );
- // 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,
- }),
- hotkeys: {
- completeRenaming: completeRenamingHotkey
- }
- });
- // Notify parent when checked items change
- // biome-ignore lint/correctness/useExhaustiveDependencies: createNotifyEffect already includes checkedItemIds in its closure
- useEffect(createNotifyEffect(tree, onCheckedItemsChange), [
- createNotifyEffect,
- tree,
- ]);
- // Subscribe to Socket.io UpdateDescCount events
- useSocketUpdateDescCount();
- // 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 getScrollElement = useCallback(
- () => scrollerElem ?? null,
- [scrollerElem],
- );
- const stableEstimateSize = useCallback(() => {
- return estimateTreeItemSize();
- }, [estimateTreeItemSize]);
- const measureElement = useCallback(
- (element: Element | null) => {
- // Return consistent height measurement
- return element?.getBoundingClientRect().height ?? stableEstimateSize();
- },
- [stableEstimateSize],
- );
- const virtualizer = useVirtualizer({
- count: items.length,
- getScrollElement,
- estimateSize: stableEstimateSize,
- overscan: 5,
- measureElement,
- });
- // Scroll to selected item on mount or when targetPathOrId changes
- useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
- return (
- <div
- {...tree.getContainerProps()}
- className="list-group position-relative"
- style={{ height: `${virtualizer.getTotalSize()}px` }}
- >
- {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}
- style={{
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- transform: `translateY(${virtualItem.start}px)`,
- }}
- ref={(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 dndProperties when D&D is enabled) */}
- {renderDragLine(tree)}
- </div>
- );
- };
|