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

+ 8 - 4
.serena/memories/apps-app-simplified-items-tree-virtualization-plan.md

@@ -171,19 +171,23 @@ src/client/components/Common/SimplifiedItemsTree/
 
 **実装方針**: 既存実装よりも @headless-tree の機能を使って新規実装、APIは既存を使用
 
-6. **Drag and Drop**
+6. **Create**
+   - @headless-tree/core の renameFeature を活用
+   - 仮のノードを追加してから renameFeature によりページ名を入力、確定したら API を呼び出してページの実態を作成する
+
+7. **Drag and Drop**
    - @headless-tree/core の dragAndDropFeature を活用
    - 既存の移動API(mutation)を使用
    
-7. **Rename**
+8. **Rename**
    - @headless-tree/core の renameFeature を活用
    - 既存のrename API(mutation)を使用
    
-8. **Duplicate**
+9. **Duplicate**
    - SimplifiedTreeItem にDuplicateボタンの挙動を実装
    - 既存のduplicate API(mutation)を使用
    
-9. **Delete**
+10. **Delete**
    - SimplifiedTreeItem にDeleteボタンの挙動を実装
    - 既存のdelete API(mutation)を使用
 

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

@@ -1,44 +1,3 @@
 .simplified-items-tree {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-}
-
-.simplified-tree-item {
-  display: flex;
-  gap: 8px;
-  align-items: center;
-  padding: 8px 12px;
-  cursor: pointer;
-  border-radius: 4px;
-  transition: background-color 0.2s;
-
-  &:hover {
-    background-color: rgba(0, 0, 0, 0.05);
-  }
-
-  &[aria-current='page'] {
-    font-weight: 600;
-    background-color: rgba(0, 123, 255, 0.1);
-  }
-}
-
-.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;
+  // Container for virtualized tree items
 }

+ 23 - 9
apps/app/src/client/components/Common/SimplifiedItemsTree/SimplifiedItemsTree.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import { useCallback, useRef } from 'react';
+import { useCallback, useRef, useState } from 'react';
 
 import { asyncDataLoaderFeature } from '@headless-tree/core';
 import { useTree } from '@headless-tree/react';
@@ -28,11 +28,20 @@ function constructRootPageForVirtualRoot(rootPageId: string, allPagesCount: numb
 }
 
 type Props = {
+  targetPath: string;
   targetPathOrId?: string | null;
+  isWipPageShown?: boolean;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
 };
 
-export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
+export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
+  const {
+    targetPath, targetPathOrId, isWipPageShown = true, isEnableActions = false, isReadOnlyUser = false,
+  } = props;
+
   const scrollElementRef = useRef<HTMLDivElement>(null);
+  const [, setRebuildTrigger] = useState(0);
 
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const rootPage = rootPageResult?.rootPage;
@@ -123,6 +132,11 @@ export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
             return null;
           }
 
+          // Skip rendering WIP pages if not shown
+          if (!isWipPageShown && itemData.wip) {
+            return null;
+          }
+
           const isSelected = targetPathOrId === itemData._id || targetPathOrId === itemData.path;
           const props = item.getProps();
 
@@ -136,13 +150,6 @@ export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
                   (props.ref as (node: HTMLElement) => void)(node);
                 }
               }}
-              style={{
-                position: 'absolute',
-                top: 0,
-                left: 0,
-                width: '100%',
-                transform: `translateY(${virtualItem.start}px)`,
-              }}
             >
               <SimplifiedTreeItem
                 item={itemData}
@@ -150,6 +157,11 @@ export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
                 level={item.getItemMeta().level}
                 isExpanded={item.isExpanded()}
                 isFolder={item.isFolder()}
+                targetPath={targetPath}
+                targetPathOrId={targetPathOrId}
+                isWipPageShown={isWipPageShown}
+                isEnableActions={isEnableActions}
+                isReadOnlyUser={isReadOnlyUser}
                 onToggle={() => {
                   if (item.isExpanded()) {
                     item.collapse();
@@ -157,6 +169,8 @@ export const SimplifiedItemsTree: FC<Props> = ({ targetPathOrId }) => {
                   else {
                     item.expand();
                   }
+                  // Trigger re-render to show/hide children
+                  setRebuildTrigger(prev => prev + 1);
                 }}
               />
             </div>

+ 34 - 25
apps/app/src/client/components/Common/SimplifiedItemsTree/SimplifiedTreeItem.tsx

@@ -5,7 +5,9 @@ import { useRouter } from 'next/router';
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 
-import styles from './SimplifiedItemsTree.module.scss';
+import { CountBadgeForPageTreeItem } from '../../Sidebar/PageTreeItem/CountBadgeForPageTreeItem';
+import { usePageItemControl } from '../../Sidebar/PageTreeItem/use-page-item-control';
+import { TreeItemLayout } from '../../TreeItem';
 
 
 type Props = {
@@ -14,46 +16,53 @@ type Props = {
   level: number;
   isExpanded: boolean;
   isFolder: boolean;
+  targetPath: string;
+  targetPathOrId?: string | null;
+  isWipPageShown?: boolean;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
   onToggle: () => void;
 };
 
 export const SimplifiedTreeItem: FC<Props> = ({
   item,
-  isSelected,
   level,
   isExpanded,
-  isFolder,
+  targetPath,
+  targetPathOrId,
+  isWipPageShown,
+  isEnableActions = false,
+  isReadOnlyUser = false,
   onToggle,
 }) => {
   const router = useRouter();
+  const { Control } = usePageItemControl();
 
   const handleClick = useCallback(() => {
+    if (item.path == null || item._id == null) return;
     router.push(item.path);
-  }, [router, item.path]);
+  }, [router, item.path, item._id]);
 
-  const handleToggle = useCallback((e: React.MouseEvent) => {
-    e.stopPropagation();
-    onToggle();
-  }, [onToggle]);
+  const handleWheelClick = useCallback(() => {
+    if (item.path == null || item._id == null) return;
+    window.open(item.path, '_blank');
+  }, [item.path, item._id]);
 
   return (
-    <div
-      className={styles['simplified-tree-item']}
+    <TreeItemLayout
+      item={item}
+      itemLevel={level}
+      targetPath={targetPath}
+      targetPathOrId={targetPathOrId ?? undefined}
+      isOpen={isExpanded}
+      isWipPageShown={isWipPageShown}
+      isEnableActions={isEnableActions}
+      isReadOnlyUser={isReadOnlyUser}
       onClick={handleClick}
-      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>
+      onWheelClick={handleWheelClick}
+      onToggleOpen={onToggle}
+      customEndComponents={[CountBadgeForPageTreeItem]}
+      customHoveredEndComponents={[Control]}
+    />
   );
 };

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

@@ -189,6 +189,10 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
       />
       */}
       <SimplifiedItemsTree
+        isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
+        isWipPageShown={isWipPageShown}
+        targetPath={path}
         targetPathOrId={targetPathOrId}
       />
 

+ 1 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -8,7 +8,7 @@ import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
 export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
   const { getDescCount } = usePageTreeDescCountMap();
 
-  const { page } = props.itemNode;
+  const { item: page } = props;
 
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 

+ 2 - 4
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -39,12 +39,11 @@ export const usePageItemControl = (): UsePageItemControl => {
 
   const Control: FC<TreeItemToolProps> = (props) => {
     const {
-      itemNode,
+      item: page,
       isEnableActions,
       isReadOnlyUser,
       onClickDuplicateMenuItem, onClickDeleteMenuItem,
     } = props;
-    const { page } = itemNode;
 
     const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
     const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
@@ -135,8 +134,7 @@ export const usePageItemControl = (): UsePageItemControl => {
 
 
   const RenameInput: FC<TreeItemToolProps> = (props) => {
-    const { itemNode, onRenamed } = props;
-    const { page } = itemNode;
+    const { item: page, onRenamed } = props;
 
     const parentRef = useRef<HTMLDivElement>(null);
     const [parentRect] = useRect(parentRef);

+ 18 - 84
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -1,10 +1,7 @@
 import React, {
   useCallback,
-  useState,
   useEffect,
   useMemo,
-  type RefObject,
-  type RefCallback,
   type MouseEvent,
   type JSX,
 } from 'react';
@@ -12,9 +9,7 @@ import React, {
 import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
 
 import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
-import { useSWRxPageChildren } from '~/stores/page-listing';
 
-import { ItemNode } from './ItemNode';
 import { SimpleItemContent } from './SimpleItemContent';
 import type { TreeItemProps, TreeItemToolProps } from './interfaces';
 
@@ -26,30 +21,23 @@ const moduleClass = styles['tree-item-layout'] ?? '';
 
 type TreeItemLayoutProps = TreeItemProps & {
   className?: string,
-  itemRef?: RefObject<any> | RefCallback<any>,
   indentSize?: number,
+  onToggleOpen?: () => void,
 }
 
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
   const {
     className, itemClassName,
     indentSize = 10,
+    item: page,
     itemLevel: baseItemLevel = 1,
-    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
+    targetPath, targetPathOrId, isOpen = false,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
-    itemRef, itemClass,
     showAlternativeContent,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
+    onToggleOpen,
   } = props;
 
-  const { page } = itemNode;
-
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
-  const [isOpen, setIsOpen] = useState(_isOpen);
-
-  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
-
-
   const itemClickHandler = useCallback((e: MouseEvent) => {
     // DO NOT handle the event when e.currentTarget and e.target is different
     if (e.target !== e.currentTarget) {
@@ -79,64 +67,35 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 
   // hasDescendants flag
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const hasChildren = useCallback((): boolean => {
-    return currentChildren != null && currentChildren.length > 0;
-  }, [currentChildren]);
-
-  const onClickLoadChildren = useCallback(() => {
-    setIsOpen(!isOpen);
-  }, [isOpen]);
+  const hasDescendants = descendantCount > 0;
 
+  // auto open if targetPath is descendant of this page
   useEffect(() => {
+    if (isOpen) return;
+
     const isPathToTarget = page.path != null
       && targetPath.startsWith(addTrailingSlash(page.path))
       && targetPath !== page.path; // Target Page does not need to be opened
-    if (isPathToTarget) setIsOpen(true);
-  }, [targetPath, page.path]);
 
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, targetPathOrId]);
+    if (isPathToTarget) onToggleOpen?.();
+  }, [targetPath, page.path, isOpen, onToggleOpen]);
 
   const isSelected = useMemo(() => {
     return page._id === targetPathOrId || page.path === targetPathOrId;
   }, [page, targetPathOrId]);
 
-  const ItemClassFixed = itemClass ?? TreeItemLayout;
-
-  const baseProps: Omit<TreeItemProps, 'itemLevel' | 'itemNode'> = {
+  const toolProps: TreeItemToolProps = {
+    item: page,
+    itemLevel: baseItemLevel,
     isEnableActions,
     isReadOnlyUser,
-    isOpen: false,
-    isWipPageShown,
-    targetPath,
-    targetPathOrId,
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDeleteMenuItem,
   };
 
-  const toolProps: TreeItemToolProps = {
-    ...baseProps,
-    itemLevel: baseItemLevel,
-    itemNode,
-    stateHandlers: {
-      setIsOpen,
-    },
-  };
-
   const EndComponents = props.customEndComponents;
   const HoveredEndComponents = props.customHoveredEndComponents;
-  const HeadObChildrenComponents = props.customHeadOfChildrenComponents;
   const AlternativeComponents = props.customAlternativeComponents;
 
   if (!isWipPageShown && page.wip) {
@@ -151,9 +110,10 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
       style={{ paddingLeft: `${baseItemLevel > 1 ? indentSize : 0}px` }}
     >
       <li
-        ref={itemRef}
         role="button"
-        className={`list-group-item list-group-item-action ${itemClassName}
+        className={`list-group-item list-group-item-action
+          ${isSelected ? 'active' : ''}
+          ${itemClassName ?? ''}
           border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
         id={`grw-pagetree-list-${page._id}`}
         onClick={itemClickHandler}
@@ -166,7 +126,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
             <button
               type="button"
               className={`btn btn-triangle p-0 ${isOpen ? 'open' : ''}`}
-              onClick={onClickLoadChildren}
+              onClick={onToggleOpen}
             >
               <div className="d-flex justify-content-center">
                 <span className="material-symbols-outlined fs-5">arrow_right</span>
@@ -202,32 +162,6 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
         }
 
       </li>
-      {isOpen && (
-        <div className={`tree-item-layout-children level-${baseItemLevel + 1}`}>
-
-          {HeadObChildrenComponents?.map((HeadObChildrenContents, index) => (
-            // eslint-disable-next-line react/no-array-index-key
-            (<HeadObChildrenContents key={index} {...toolProps} itemLevel={baseItemLevel + 1} />)
-          ))}
-
-          {hasChildren() && currentChildren.map((node) => {
-            const itemProps = {
-              ...baseProps,
-              className,
-              itemLevel: baseItemLevel + 1,
-              itemNode: node,
-              itemClass,
-              itemClassName,
-              onClick,
-            };
-
-            return (
-              <ItemClassFixed key={node.page._id} {...itemProps} />
-            );
-          })}
-
-        </div>
-      )}
     </div>
   );
 };

+ 1 - 4
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -3,11 +3,9 @@ import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 
-import type { ItemNode } from '../ItemNode';
-
 type TreeItemBaseProps = {
+  item: IPageForItem,
   itemLevel?: number,
-  itemNode: ItemNode,
   isEnableActions: boolean,
   isReadOnlyUser: boolean,
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
@@ -26,7 +24,6 @@ export type TreeItemProps = TreeItemBaseProps & {
   targetPathOrId?:string,
   isOpen?: boolean,
   isWipPageShown?: boolean,
-  itemClass?: React.FunctionComponent<TreeItemProps>,
   itemClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,