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

implement SimplifiedItemsTree and SimplifiedTreeItem components

Yuki Takei 5 месяцев назад
Родитель
Сommit
0c0a88677a

+ 19 - 1
apps/app/src/client/components/Common/SimplifiedItemsTree/SimplifiedItemsTree.module.scss

@@ -1,10 +1,13 @@
 .simplified-items-tree {
   display: flex;
   flex-direction: column;
-  gap: 4px;
+  gap: 2px;
 }
 
 .simplified-tree-item {
+  display: flex;
+  gap: 8px;
+  align-items: center;
   padding: 8px 12px;
   cursor: pointer;
   border-radius: 4px;
@@ -20,6 +23,21 @@
   }
 }
 
+.toggle-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  font-size: 10px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    opacity: 0.7;
+  }
+}
+
 .item-path {
   font-size: 14px;
   color: inherit;

+ 121 - 47
apps/app/src/client/components/Common/SimplifiedItemsTree/SimplifiedItemsTree.tsx

@@ -1,6 +1,11 @@
 import type { FC } from 'react';
-import { useMemo } from 'react';
+import { useCallback, useRef } from 'react';
 
+import { asyncDataLoaderFeature } from '@headless-tree/core';
+import { useTree } from '@headless-tree/react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
 import type { IPageForTreeItem } from '~/interfaces/page';
 
 import { SimplifiedTreeItem } from './SimplifiedTreeItem';
@@ -12,55 +17,124 @@ type Props = {
   targetPathOrId?: string | null;
 };
 
-// Mock data for M1 - will be replaced with real API in M2
-const MOCK_DATA: IPageForTreeItem[] = [
-  {
-    _id: '1',
-    path: '/page1',
-    parent: '/',
-    descendantCount: 0,
-    revision: 'rev1',
-    grant: 1,
-    isEmpty: false,
-    wip: false,
-  },
-  {
-    _id: '2',
-    path: '/page2',
-    parent: '/',
-    descendantCount: 0,
-    revision: 'rev2',
-    grant: 1,
-    isEmpty: false,
-    wip: false,
-  },
-  {
-    _id: '3',
-    path: '/page3',
-    parent: '/',
-    descendantCount: 0,
-    revision: 'rev3',
-    grant: 1,
-    isEmpty: false,
-    wip: false,
-  },
-];
-
 export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
-  const items = useMemo(() => MOCK_DATA, []);
+  const scrollElementRef = useRef<HTMLDivElement>(null);
+
+  const getItem = useCallback(async (itemId: string): Promise<IPageForTreeItem> => {
+    if (itemId === '/') {
+      const response = await apiv3Get<{ rootPage: IPageForTreeItem }>('/page-listing/root');
+      return response.data.rootPage;
+    }
+
+    const response = await apiv3Get<{ item: IPageForTreeItem }>('/page-listing/item', { id: itemId });
+    return response.data.item;
+  }, []);
+
+  const getChildrenWithData = useCallback(async (itemId: string) => {
+    if (itemId === '/') {
+      const rootResponse = await apiv3Get<{ rootPage: IPageForTreeItem }>('/page-listing/root');
+      const rootPageId = rootResponse.data.rootPage._id;
+      const childrenResponse = await apiv3Get<{ children: IPageForTreeItem[] }>('/page-listing/children', { id: rootPageId });
+      return childrenResponse.data.children.map(child => ({
+        id: child._id,
+        data: child,
+      }));
+    }
+
+    const response = await apiv3Get<{ children: IPageForTreeItem[] }>('/page-listing/children', { id: itemId });
+    return response.data.children.map(child => ({
+      id: child._id,
+      data: child,
+    }));
+  }, []);
+
+  const tree = useTree<IPageForTreeItem>({
+    rootItemId: '/',
+    getItemName: item => item.getItemData().path,
+    isItemFolder: item => item.getItemData().descendantCount > 0,
+    createLoadingItemData: () => ({
+      _id: '',
+      path: 'Loading...',
+      parent: '',
+      descendantCount: 0,
+      revision: '',
+      grant: 1,
+      isEmpty: false,
+      wip: false,
+    }),
+    dataLoader: {
+      getItem,
+      getChildrenWithData,
+    },
+    features: [asyncDataLoaderFeature],
+  });
+
+  const items = tree.getItems();
+
+  const virtualizer = useVirtualizer({
+    count: items.length,
+    getScrollElement: () => scrollElementRef.current,
+    estimateSize: () => 36,
+    overscan: 5,
+  });
 
   return (
-    <div className={styles['simplified-items-tree']}>
-      {items.map((item) => {
-        const isSelected = targetPathOrId === item._id || targetPathOrId === item.path;
-        return (
-          <SimplifiedTreeItem
-            key={item._id}
-            item={item}
-            isSelected={isSelected}
-          />
-        );
-      })}
+    <div
+      {...tree.getContainerProps()}
+      ref={scrollElementRef}
+      className={styles['simplified-items-tree']}
+      style={{ height: '100%', overflow: 'auto' }}
+    >
+      <div
+        style={{
+          height: `${virtualizer.getTotalSize()}px`,
+          width: '100%',
+          position: 'relative',
+        }}
+      >
+        {virtualizer.getVirtualItems().map((virtualItem) => {
+          const item = items[virtualItem.index];
+          const itemData = item.getItemData();
+          const isSelected = targetPathOrId === itemData._id || targetPathOrId === itemData.path;
+          const props = item.getProps();
+
+          return (
+            <div
+              key={virtualItem.key}
+              data-index={virtualItem.index}
+              ref={(node) => {
+                virtualizer.measureElement(node);
+                if (node && props.ref) {
+                  (props.ref as (node: HTMLElement) => void)(node);
+                }
+              }}
+              style={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                width: '100%',
+                transform: `translateY(${virtualItem.start}px)`,
+              }}
+            >
+              <SimplifiedTreeItem
+                item={itemData}
+                isSelected={isSelected}
+                level={item.getItemMeta().level}
+                isExpanded={item.isExpanded()}
+                isFolder={item.isFolder()}
+                onToggle={() => {
+                  if (item.isExpanded()) {
+                    item.collapse();
+                  }
+                  else {
+                    item.expand();
+                  }
+                }}
+              />
+            </div>
+          );
+        })}
+      </div>
     </div>
   );
 };

+ 26 - 1
apps/app/src/client/components/Common/SimplifiedItemsTree/SimplifiedTreeItem.tsx

@@ -11,15 +11,31 @@ import styles from './SimplifiedItemsTree.module.scss';
 type Props = {
   item: IPageForTreeItem;
   isSelected: boolean;
+  level: number;
+  isExpanded: boolean;
+  isFolder: boolean;
+  onToggle: () => void;
 };
 
-export const SimplifiedTreeItem: FC<Props> = ({ item, isSelected }) => {
+export const SimplifiedTreeItem: FC<Props> = ({
+  item,
+  isSelected,
+  level,
+  isExpanded,
+  isFolder,
+  onToggle,
+}) => {
   const router = useRouter();
 
   const handleClick = useCallback(() => {
     router.push(item.path);
   }, [router, item.path]);
 
+  const handleToggle = useCallback((e: React.MouseEvent) => {
+    e.stopPropagation();
+    onToggle();
+  }, [onToggle]);
+
   return (
     <div
       className={styles['simplified-tree-item']}
@@ -27,7 +43,16 @@ export const SimplifiedTreeItem: FC<Props> = ({ item, isSelected }) => {
       aria-current={isSelected ? 'page' : undefined}
       role="button"
       tabIndex={0}
+      style={{ paddingLeft: `${level * 20}px` }}
     >
+      {isFolder && (
+        <span
+          className={styles['toggle-icon']}
+          onClick={handleToggle}
+        >
+          {isExpanded ? '▼' : '▶'}
+        </span>
+      )}
       <span className={styles['item-path']}>{item.path}</span>
     </div>
   );