Просмотр исходного кода

feat: add drag and drop functionality to page tree

Yuki Takei 4 месяцев назад
Родитель
Сommit
1987f94adf

+ 1 - 0
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -119,6 +119,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     <div className="pt-4">
       <SimplifiedItemsTree
         enableRenaming
+        enableDragAndDrop
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
         isWipPageShown={isWipPageShown}

+ 48 - 4
apps/app/src/features/page-tree/components/SimplifiedItemsTree.tsx

@@ -1,8 +1,10 @@
 import type { FC } from 'react';
-import { useMemo } 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';
 
@@ -18,6 +20,7 @@ import {
   useTreeItemHandlers,
   useTreeRevalidation,
 } from '../hooks/_inner';
+import { usePageDnd, useSetEnableDragAndDrop } from '../hooks/use-page-dnd';
 import type { TreeItemProps } from '../interfaces';
 import { useTriggerTreeRebuild } from '../states/_inner';
 
@@ -44,6 +47,7 @@ type Props = {
   // Feature options
   enableRenaming?: boolean;
   enableCheckboxes?: boolean;
+  enableDragAndDrop?: boolean;
   initialCheckedItems?: string[];
   onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
 };
@@ -60,10 +64,12 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     scrollerElem,
     enableRenaming = false,
     enableCheckboxes = false,
+    enableDragAndDrop = false,
     initialCheckedItems = [],
     onCheckedItemsChange,
   } = props;
 
+  const { t } = useTranslation();
   const triggerTreeRebuild = useTriggerTreeRebuild();
 
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
@@ -81,8 +87,33 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
   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,
@@ -112,6 +143,13 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     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
@@ -167,7 +205,9 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
           return null;
         }
 
-        const treeItemProps = item.getProps();
+        const { ref: itemRef, ...itemProps } = item.getProps();
+        // Exclude onClick from itemProps to prevent conflicts
+        const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
 
         return (
           <div
@@ -175,10 +215,12 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
             data-index={virtualItem.index}
             ref={(node) => {
               virtualizer.measureElement(node);
-              if (node && treeItemProps.ref) {
-                (treeItemProps.ref as (node: HTMLElement) => void)(node);
+              if (node && itemRef) {
+                (itemRef as (node: HTMLElement) => void)(node);
               }
             }}
+            // Apply props
+            {...itemPropsWithoutOnClick}
           >
             <CustomTreeItem
               item={item}
@@ -192,6 +234,8 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
           </div>
         );
       })}
+      {/* Drag line indicator (rendered by usePageDnd when D&D is enabled) */}
+      {renderDragLine(tree)}
     </div>
   );
 };

+ 7 - 0
apps/app/src/features/page-tree/components/TreeItemLayout.module.scss

@@ -12,6 +12,13 @@
           display: flex !important;
         }
       }
+
+      // Drag target state styling
+      &.drag-target {
+        background-color: var(--bs-list-group-active-bg) !important;
+        outline: 3px dashed var(--bs-list-group-active-border-color);
+        outline-offset: -4px;
+      }
     }
   }
 }

+ 8 - 2
apps/app/src/features/page-tree/components/TreeItemLayout.tsx

@@ -1,6 +1,8 @@
+import type {
+  JSX,
+  MouseEvent,
+} from 'react';
 import {
-  type JSX,
-  type MouseEvent,
   useCallback,
   useMemo,
 } from 'react';
@@ -84,6 +86,9 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     return page._id === targetPathOrId || page.path === targetPathOrId;
   }, [page, targetPathOrId]);
 
+  // Check if this item is a drag target (being dragged over)
+  const isDragTarget = item.isDragTarget?.() ?? false;
+
   const toolProps: TreeItemToolProps = {
     item,
     isEnableActions,
@@ -113,6 +118,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
       <li
         className={`list-group-item list-group-item-action
           ${isSelected ? 'active' : ''}
+          ${isDragTarget ? 'drag-target' : ''}
           ${itemClassName ?? ''}
           border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
         id={`grw-pagetree-list-${page._id}`}

+ 12 - 2
apps/app/src/features/page-tree/hooks/_inner/use-tree-features.ts

@@ -3,6 +3,7 @@ import type { FeatureImplementation } from '@headless-tree/core';
 import {
   asyncDataLoaderFeature,
   checkboxesFeature,
+  dragAndDropFeature,
   hotkeysCoreFeature,
   renamingFeature,
   selectionFeature,
@@ -11,6 +12,7 @@ import {
 export type UseTreeFeaturesOptions = {
   enableRenaming?: boolean;
   enableCheckboxes?: boolean;
+  enableDragAndDrop?: boolean;
 };
 
 /**
@@ -20,7 +22,11 @@ export type UseTreeFeaturesOptions = {
 export const useTreeFeatures = (
   options: UseTreeFeaturesOptions = {},
 ): FeatureImplementation<unknown>[] => {
-  const { enableRenaming = true, enableCheckboxes = false } = options;
+  const {
+    enableRenaming = true,
+    enableCheckboxes = false,
+    enableDragAndDrop = false,
+  } = options;
 
   return useMemo(() => {
     const features: FeatureImplementation<unknown>[] = [
@@ -37,6 +43,10 @@ export const useTreeFeatures = (
       features.push(checkboxesFeature);
     }
 
+    if (enableDragAndDrop) {
+      features.push(dragAndDropFeature);
+    }
+
     return features;
-  }, [enableRenaming, enableCheckboxes]);
+  }, [enableRenaming, enableCheckboxes, enableDragAndDrop]);
 };

+ 1 - 0
apps/app/src/features/page-tree/hooks/index.ts

@@ -1,3 +1,4 @@
 export * from './use-page-create';
+export * from './use-page-dnd';
 export * from './use-page-rename';
 export * from './use-placeholder-rename-effect';

+ 7 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.module.scss

@@ -0,0 +1,7 @@
+// Drag line indicator for drag and drop
+.tree-drag-line {
+  position: relative;
+  height: 3px;
+  pointer-events: none;
+  background-color: var(--bs-list-group-active-border-color);
+}

+ 97 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.spec.ts

@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  getNewPathAfterMoved,
+  hasAncestorDescendantRelation,
+} from './use-page-dnd';
+
+describe('getNewPathAfterMoved', () => {
+  it('should return correct path when moving to root', () => {
+    expect(getNewPathAfterMoved('/A/B', '/')).toBe('/B');
+  });
+
+  it('should return correct path when moving to nested parent', () => {
+    expect(getNewPathAfterMoved('/A/B', '/C/D')).toBe('/C/D/B');
+  });
+
+  it('should handle page with special characters in name', () => {
+    expect(getNewPathAfterMoved('/A/Page Name', '/B')).toBe('/B/Page Name');
+  });
+
+  it('should handle deeply nested paths', () => {
+    expect(getNewPathAfterMoved('/A/B/C/D', '/X/Y')).toBe('/X/Y/D');
+  });
+
+  it('should handle moving from root child to another location', () => {
+    expect(getNewPathAfterMoved('/PageA', '/Folder')).toBe('/Folder/PageA');
+  });
+
+  it('should handle Japanese characters in page name', () => {
+    expect(getNewPathAfterMoved('/A/ページ名', '/B')).toBe('/B/ページ名');
+  });
+});
+
+describe('hasAncestorDescendantRelation', () => {
+  // Helper to create mock item instances
+  const createMockItem = (path: string | null) => ({
+    getItemData: () => ({ path }),
+  });
+
+  it('should return true when parent and child are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/A/B')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return true when grandparent and grandchild are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/A/B/C')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return true when child and parent are in reverse order', () => {
+    const items = [createMockItem('/A/B'), createMockItem('/A')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return false when siblings are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/B')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false for single item', () => {
+    const items = [createMockItem('/A')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false for empty array', () => {
+    expect(hasAncestorDescendantRelation([])).toBe(false);
+  });
+
+  it('should return false when paths are similar but not ancestor-descendant', () => {
+    // /A and /AB are not ancestor-descendant
+    const items = [createMockItem('/A'), createMockItem('/AB')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should handle items with null paths', () => {
+    const items = [createMockItem('/A'), createMockItem(null)];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false when multiple siblings are selected', () => {
+    const items = [
+      createMockItem('/A/B'),
+      createMockItem('/A/C'),
+      createMockItem('/A/D'),
+    ];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return true when one item is ancestor of another in multiple selection', () => {
+    const items = [
+      createMockItem('/X'),
+      createMockItem('/A/B'),
+      createMockItem('/A/B/C'),
+    ];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+});

+ 276 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.tsx

@@ -0,0 +1,276 @@
+import type { CSSProperties, FC, ReactNode } from 'react';
+import { useCallback, useMemo } from 'react';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import type { DragTarget, ItemInstance, TreeInstance } from '@headless-tree/core';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { basename, join } from 'pathe';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+import styles from './use-page-dnd.module.scss';
+
+/**
+ * Calculate new path after moving a page to a new parent
+ * @param fromPath - The original path of the page being moved
+ * @param newParentPath - The path of the new parent page
+ * @returns The new path after the move
+ */
+export const getNewPathAfterMoved = (
+  fromPath: string,
+  newParentPath: string,
+): string => {
+  const pageTitle = basename(fromPath);
+  return join(newParentPath, pageTitle);
+};
+
+/**
+ * Check if selected items have ancestor-descendant relationship
+ * (e.g., if both /A and /A/B are selected, they have an ancestor-descendant relationship)
+ * @param items - Array of tree item instances
+ * @returns true if any pair has ancestor-descendant relationship
+ */
+export const hasAncestorDescendantRelation = (
+  items: ItemInstance<IPageForTreeItem>[],
+): boolean => {
+  const paths = items
+    .map((item) => item.getItemData().path)
+    .filter((path): path is string => path != null);
+
+  for (let i = 0; i < paths.length; i++) {
+    for (let j = 0; j < paths.length; j++) {
+      if (i === j) continue;
+      // Check if paths[i] is an ancestor of paths[j]
+      if (paths[j].startsWith(`${paths[i]}/`)) {
+        return true;
+      }
+    }
+  }
+  return false;
+};
+
+/**
+ * Error types for page move operations
+ */
+export type PageMoveErrorType = 'operation_blocked' | 'unknown';
+
+/**
+ * Result of a page move operation
+ */
+export type PageMoveResult = {
+  success: boolean;
+  errorType?: PageMoveErrorType;
+};
+
+/**
+ * Atom to track if drag and drop is enabled
+ */
+const enableDragAndDropAtom = atom(false);
+
+/**
+ * Props for DragLine component
+ */
+type DragLineProps = {
+  style: CSSProperties;
+  className?: string;
+};
+
+/**
+ * Drag line indicator component
+ */
+const DragLine: FC<DragLineProps> = ({ style, className }) => (
+  <div style={style} className={`${styles['tree-drag-line']} ${className ?? ''}`} />
+);
+
+export type UsePageDndResult = {
+  canDrag: (items: ItemInstance<IPageForTreeItem>[]) => boolean;
+  canDrop: (
+    items: ItemInstance<IPageForTreeItem>[],
+    target: DragTarget<IPageForTreeItem>,
+  ) => boolean;
+  onDrop: (
+    items: ItemInstance<IPageForTreeItem>[],
+    target: DragTarget<IPageForTreeItem>,
+  ) => Promise<PageMoveResult>;
+  /**
+   * Whether drag and drop is currently enabled
+   */
+  isEnabled: boolean;
+  /**
+   * Render the drag line indicator
+   * @param tree - The tree instance from headless-tree
+   * @returns A DragLine component with proper positioning, or null if D&D is disabled
+   */
+  renderDragLine: (tree: TreeInstance<IPageForTreeItem>) => ReactNode;
+};
+
+/**
+ * Hook to handle page drag and drop operations
+ *
+ * Responsibilities:
+ * - Determine if items can be dragged (canDrag)
+ * - Determine if items can be dropped on a target (canDrop)
+ * - Execute page move API call and tree refresh (onDrop)
+ * - Track enable state (isEnabled)
+ * - Provide drag line rendering (renderDragLine)
+ *
+ * Note: Toast notifications should be handled by the caller based on PageMoveResult
+ *
+ * @returns Object with canDrag, canDrop, onDrop handlers, isEnabled state, and renderDragLine
+ */
+export const usePageDnd = (): UsePageDndResult => {
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+  const isEnabled = useAtomValue(enableDragAndDropAtom);
+
+  /**
+   * Determine if items can be dragged
+   */
+  const canDrag = useCallback(
+    (items: ItemInstance<IPageForTreeItem>[]): boolean => {
+      // Prevent drag if ancestor-descendant relationship exists
+      if (hasAncestorDescendantRelation(items)) {
+        return false;
+      }
+
+      // Check if all items can be dragged
+      return items.every((item) => {
+        const page = item.getItemData();
+        if (page.path == null) return false;
+        // Protected user pages cannot be dragged
+        return !pagePathUtils.isUsersProtectedPages(page.path);
+      });
+    },
+    [],
+  );
+
+  /**
+   * Determine if items can be dropped on target
+   */
+  const canDrop = useCallback(
+    (
+      items: ItemInstance<IPageForTreeItem>[],
+      target: DragTarget<IPageForTreeItem>,
+    ): boolean => {
+      const targetItem = target.item;
+      if (targetItem == null) return false;
+
+      const targetPage = targetItem.getItemData();
+      if (targetPage.path == null) return false;
+
+      // Prevent drop on users top page
+      if (pagePathUtils.isUsersTopPage(targetPage.path)) {
+        return false;
+      }
+
+      // Check if all items can be moved to the target
+      return items.every((item) => {
+        const fromPage = item.getItemData();
+        if (fromPage.path == null) return false;
+
+        const newPathAfterMoved = getNewPathAfterMoved(
+          fromPage.path,
+          targetPage.path,
+        );
+        return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+      });
+    },
+    [],
+  );
+
+  /**
+   * Handle drop event - move pages to new parent
+   * Returns result with success/failure info for caller to handle UI feedback
+   */
+  const onDrop = useCallback(
+    async (
+      items: ItemInstance<IPageForTreeItem>[],
+      target: DragTarget<IPageForTreeItem>,
+    ): Promise<PageMoveResult> => {
+      const targetItem = target.item;
+      if (targetItem == null) return { success: false, errorType: 'unknown' };
+
+      const targetPage = targetItem.getItemData();
+      if (targetPage.path == null) return { success: false, errorType: 'unknown' };
+
+      // Collect parent IDs for tree invalidation
+      const parentIdsToInvalidate = new Set<string>();
+
+      for (const item of items) {
+        const fromPage = item.getItemData();
+        if (fromPage.path == null) continue;
+
+        // Track original parent for invalidation
+        if (fromPage.parent) {
+          parentIdsToInvalidate.add(String(fromPage.parent));
+        }
+
+        const newPagePath = getNewPathAfterMoved(
+          fromPage.path,
+          targetPage.path,
+        );
+
+        try {
+          await apiv3Put('/pages/rename', {
+            pageId: fromPage._id,
+            revisionId: fromPage.revision,
+            newPagePath,
+            isRenameRedirect: false,
+            updateMetadata: true,
+          });
+        }
+        catch (err) {
+          const errorType: PageMoveErrorType = (err as { code?: string }).code === 'operation__blocked'
+            ? 'operation_blocked'
+            : 'unknown';
+          return { success: false, errorType };
+        }
+      }
+
+      // Add target (new parent) to invalidation list
+      parentIdsToInvalidate.add(targetPage._id);
+
+      // Refresh SWR cache
+      await mutatePageTree();
+
+      // Invalidate headless-tree items (source parents and target)
+      notifyUpdateItems(Array.from(parentIdsToInvalidate));
+
+      // Expand drop target
+      targetItem.expand();
+
+      return { success: true };
+    },
+    [notifyUpdateItems],
+  );
+
+  /**
+   * Render the drag line indicator
+   * Returns null if D&D is disabled
+   */
+  const renderDragLine = useCallback(
+    (tree: TreeInstance<IPageForTreeItem>): ReactNode => {
+      if (!isEnabled) return null;
+      return <DragLine style={tree.getDragLineStyle()} />;
+    },
+    [isEnabled],
+  );
+
+  return useMemo(() => ({
+    canDrag,
+    canDrop,
+    onDrop,
+    isEnabled,
+    renderDragLine,
+  }), [canDrag, canDrop, onDrop, isEnabled, renderDragLine]);
+};
+
+/**
+ * Hook to set drag and drop enabled state
+ * Used by tree container to enable/disable D&D feature
+ */
+export const useSetEnableDragAndDrop = (): ((enabled: boolean) => void) => {
+  return useSetAtom(enableDragAndDropAtom);
+};