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

+ 34 - 4
apps/app/src/client/components/Sidebar/PageTreeItem/SimplifiedPageTreeItem.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 
 
 import path from 'path';
 import path from 'path';
 
 
@@ -112,11 +112,41 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
   const { isRenaming, RenameAlternativeComponent } = usePageRename();
   const { isRenaming, RenameAlternativeComponent } = usePageRename();
 
 
   // Page create feature
   // Page create feature
-  const { CreateAlternativeComponent } = usePageCreate();
+  const { CreateAlternativeComponent, cancelCreating } = usePageCreate();
 
 
   // Check if this is the creating placeholder node
   // Check if this is the creating placeholder node
   const isCreatingPlaceholder = itemData._id === CREATING_PAGE_VIRTUAL_ID;
   const isCreatingPlaceholder = itemData._id === CREATING_PAGE_VIRTUAL_ID;
 
 
+  // Track if renaming mode was ever activated for this placeholder
+  const wasRenamingRef = useRef(false);
+  const isRenamingNow = item.isRenaming();
+
+  // Start renaming mode on placeholder node to enable getRenameInputProps()
+  useEffect(() => {
+    if (isCreatingPlaceholder && !item.isRenaming()) {
+      item.startRenaming();
+    }
+  }, [isCreatingPlaceholder, item]);
+
+  // Track when renaming becomes active
+  useEffect(() => {
+    if (isCreatingPlaceholder && isRenamingNow) {
+      wasRenamingRef.current = true;
+    }
+  }, [isCreatingPlaceholder, isRenamingNow]);
+
+  // Cancel creating when renaming mode ends on placeholder node (Esc key pressed)
+  useEffect(() => {
+    // Only cancel if renaming was previously active and is now inactive
+    if (isCreatingPlaceholder && wasRenamingRef.current && !isRenamingNow) {
+      cancelCreating();
+      wasRenamingRef.current = false;
+    }
+  }, [isCreatingPlaceholder, isRenamingNow, cancelCreating]);
+
+  // Show CreateInput only when renamingFeature is active (item.isRenaming() is true)
+  const showCreateInput = isCreatingPlaceholder && item.isRenaming();
+
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) return;
     if (page.path == null || page._id == null) return;
 
 
@@ -147,8 +177,8 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control]}
       customHoveredEndComponents={[Control]}
-      showAlternativeContent={isRenaming(item) || isCreatingPlaceholder}
-      customAlternativeComponents={isCreatingPlaceholder ? [CreateAlternativeComponent] : [RenameAlternativeComponent]}
+      showAlternativeContent={isRenaming(item) || showCreateInput}
+      customAlternativeComponents={showCreateInput ? [CreateAlternativeComponent] : [RenameAlternativeComponent]}
     />
     />
   );
   );
 };
 };

+ 18 - 56
apps/app/src/features/page-tree/client/components/CreateInput.tsx

@@ -1,5 +1,5 @@
-import type { CSSProperties, FC, KeyboardEvent } from 'react';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import type { FC, InputHTMLAttributes } from 'react';
+import { useState } from 'react';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
@@ -10,81 +10,43 @@ const wrapperClass = styles['create-input-wrapper'] ?? '';
 const inputClass = styles['create-input'] ?? '';
 const inputClass = styles['create-input'] ?? '';
 
 
 type CreateInputProps = {
 type CreateInputProps = {
+  inputProps: InputHTMLAttributes<HTMLInputElement> & {
+    ref?: (r: HTMLInputElement | null) => void;
+  };
   validateName?: (name: string) => InputValidationResult | null;
   validateName?: (name: string) => InputValidationResult | null;
-  onSubmit?: (value: string) => void;
-  onCancel?: () => void;
   className?: string;
   className?: string;
-  style?: CSSProperties;
   placeholder?: string;
   placeholder?: string;
 };
 };
 
 
 export const CreateInput: FC<CreateInputProps> = ({
 export const CreateInput: FC<CreateInputProps> = ({
+  inputProps,
   validateName,
   validateName,
-  onSubmit,
-  onCancel,
   className,
   className,
-  style,
   placeholder = 'New Page',
   placeholder = 'New Page',
 }) => {
 }) => {
-  const inputRef = useRef<HTMLInputElement>(null);
-  const [value, setValue] = useState('');
   const [validationResult, setValidationResult] =
   const [validationResult, setValidationResult] =
     useState<InputValidationResult | null>(null);
     useState<InputValidationResult | null>(null);
 
 
-  // Auto focus on mount
-  useEffect(() => {
-    inputRef.current?.focus();
-    inputRef.current?.select();
-  }, []);
-
-  const validate = debounce(300, (inputValue: string) => {
-    setValidationResult(validateName?.(inputValue) ?? null);
+  const validate = debounce(300, (value: string) => {
+    setValidationResult(validateName?.(value) ?? null);
   });
   });
 
 
-  const handleChange = useCallback(
-    (e: React.ChangeEvent<HTMLInputElement>) => {
-      const newValue = e.target.value;
-      setValue(newValue);
-      validate(newValue);
-    },
-    [validate],
-  );
-
-  const handleKeyDown = useCallback(
-    (e: KeyboardEvent<HTMLInputElement>) => {
-      if (e.key === 'Enter') {
-        e.preventDefault();
-        if (validationResult == null && value.trim() !== '') {
-          onSubmit?.(value);
-        }
-      } else if (e.key === 'Escape') {
-        e.preventDefault();
-        onCancel?.();
-      }
-    },
-    [value, validationResult, onSubmit, onCancel],
-  );
-
-  const handleBlur = useCallback(() => {
-    // Cancel if blurred without submitting
-    // Delay to allow click events on submit button (if any)
-    setTimeout(() => {
-      onCancel?.();
-    }, 150);
-  }, [onCancel]);
-
   const isInvalid = validationResult != null;
   const isInvalid = validationResult != null;
 
 
   return (
   return (
-    <div className={`${wrapperClass} ${className ?? ''}`} style={style}>
+    <div className={`${wrapperClass} ${className ?? ''}`}>
       <div className={`${inputClass} flex-fill`}>
       <div className={`${inputClass} flex-fill`}>
         <input
         <input
-          ref={inputRef}
+          {...inputProps}
+          onChange={(e) => {
+            inputProps.onChange?.(e);
+            validate(e.target.value);
+          }}
+          onBlur={(e) => {
+            setValidationResult(null);
+            inputProps.onBlur?.(e);
+          }}
           type="text"
           type="text"
-          value={value}
-          onChange={handleChange}
-          onKeyDown={handleKeyDown}
-          onBlur={handleBlur}
           placeholder={placeholder}
           placeholder={placeholder}
           className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
           className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
         />
         />

+ 21 - 3
apps/app/src/features/page-tree/client/components/SimplifiedItemsTree.tsx

@@ -14,6 +14,7 @@ import { useSWRxRootPage } from '~/stores/page-listing';
 
 
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
 import { useDataLoader } from '../hooks/use-data-loader';
 import { useDataLoader } from '../hooks/use-data-loader';
+import { usePageCreate } from '../hooks/use-page-create';
 import { usePageRename } from '../hooks/use-page-rename';
 import { usePageRename } from '../hooks/use-page-rename';
 import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
 import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
 import type { TreeItemProps } from '../interfaces';
 import type { TreeItemProps } from '../interfaces';
@@ -58,17 +59,34 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
   // Page rename hook
   // Page rename hook
   const { rename, getPageName } = usePageRename();
   const { rename, getPageName } = usePageRename();
 
 
+  // Page create hook
+  const { createFromPlaceholder, isCreatingPlaceholder, cancelCreating } = usePageCreate();
+
   // Get creating parent id to determine if item should be treated as folder
   // Get creating parent id to determine if item should be treated as folder
   const creatingParentId = useCreatingParentId();
   const creatingParentId = useCreatingParentId();
 
 
   // onRename handler for headless-tree
   // onRename handler for headless-tree
+  // Handles both rename and create (for placeholder nodes)
   const handleRename = useCallback(
   const handleRename = useCallback(
     async (item, newValue: string) => {
     async (item, newValue: string) => {
-      await rename(item, newValue);
-      // Trigger re-render after rename
+      if (isCreatingPlaceholder(item)) {
+        // Placeholder node: create new page or cancel if empty
+        if (newValue.trim() === '') {
+          // Empty value means cancel (Esc key or blur)
+          cancelCreating();
+        }
+        else {
+          await createFromPlaceholder(item, newValue);
+        }
+      }
+      else {
+        // Normal node: rename page
+        await rename(item, newValue);
+      }
+      // Trigger re-render after operation
       setRebuildTrigger((prev) => prev + 1);
       setRebuildTrigger((prev) => prev + 1);
     },
     },
-    [rename],
+    [rename, createFromPlaceholder, isCreatingPlaceholder, cancelCreating],
   );
   );
 
 
   const tree = useTree<IPageForTreeItem>({
   const tree = useTree<IPageForTreeItem>({

+ 51 - 64
apps/app/src/features/page-tree/client/hooks/use-page-create.tsx

@@ -20,6 +20,7 @@ import { shouldCreateWipPage } from '~/utils/should-create-wip-page';
 import { CreateInput } from '../components/CreateInput';
 import { CreateInput } from '../components/CreateInput';
 import type { TreeItemToolProps } from '../interfaces';
 import type { TreeItemToolProps } from '../interfaces';
 import {
 import {
+  CREATING_PAGE_VIRTUAL_ID,
   useCreatingParentId,
   useCreatingParentId,
   usePageTreeCreateActions,
   usePageTreeCreateActions,
 } from '../states/page-tree-create';
 } from '../states/page-tree-create';
@@ -40,6 +41,20 @@ type UsePageCreateReturn = {
     pageName: string,
     pageName: string,
   ) => Promise<CreateResult>;
   ) => Promise<CreateResult>;
 
 
+  /**
+   * Create a new page from the placeholder node (called by onRename handler)
+   * The placeholder node's parent is used as the parent of the new page
+   */
+  createFromPlaceholder: (
+    placeholderItem: ItemInstance<IPageForItem>,
+    pageName: string,
+  ) => Promise<CreateResult>;
+
+  /**
+   * Check if an item is the creating placeholder node
+   */
+  isCreatingPlaceholder: (item: ItemInstance<IPageForItem>) => boolean;
+
   /**
   /**
    * Validate page name
    * Validate page name
    */
    */
@@ -62,15 +77,9 @@ type UsePageCreateReturn = {
 
 
   /**
   /**
    * Alternative component for creating a child page (used in TreeItemLayout)
    * Alternative component for creating a child page (used in TreeItemLayout)
-   * This renders the CreateInput with proper indentation for child level
+   * This renders the CreateInput using item.getRenameInputProps()
    */
    */
   CreateAlternativeComponent: FC<TreeItemToolProps>;
   CreateAlternativeComponent: FC<TreeItemToolProps>;
-
-  /**
-   * @deprecated Use CreateAlternativeComponent instead
-   * CreateInput component to use as HeadOfChildrenComponent
-   */
-  CreateInputComponent: FC<TreeItemToolProps>;
 };
 };
 
 
 /**
 /**
@@ -125,6 +134,13 @@ export const usePageCreate = (): UsePageCreateReturn => {
     [creatingParentId],
     [creatingParentId],
   );
   );
 
 
+  const isCreatingPlaceholder = useCallback(
+    (item: ItemInstance<IPageForItem>): boolean => {
+      return item.getId() === CREATING_PAGE_VIRTUAL_ID;
+    },
+    [],
+  );
+
   const create = useCallback(
   const create = useCallback(
     async (
     async (
       parentItem: ItemInstance<IPageForItem>,
       parentItem: ItemInstance<IPageForItem>,
@@ -192,73 +208,44 @@ export const usePageCreate = (): UsePageCreateReturn => {
     [t, createPage, notifyUpdateItems, cancelCreating],
     [t, createPage, notifyUpdateItems, cancelCreating],
   );
   );
 
 
-  // CreateInput as alternative component for TreeItemLayout
-  // This renders inside the TreeItemLayout, replacing the normal content
-  // The item here is the placeholder node, so we need to get parent from it
-  const CreateAlternativeComponent: FC<TreeItemToolProps> = useMemo(() => {
-    const Component: FC<TreeItemToolProps> = ({ item }) => {
-      // Get the parent item (the placeholder's parent is the actual parent where we create)
-      const parentItem = item.getParent();
-
-      const handleCreate = async (value: string) => {
-        if (parentItem == null) {
-          cancelCreating();
-          return;
-        }
-        await create(parentItem, value);
-      };
-
-      const handleCancel = () => {
-        cancelCreating();
-      };
-
-      return (
-        <CreateInput
-          validateName={validateName}
-          onSubmit={handleCreate}
-          onCancel={handleCancel}
-          className="flex-grow-1"
-        />
-      );
-    };
-    return Component;
-  }, [create, cancelCreating, validateName]);
-
-  // Legacy: CreateInput as HeadOfChildrenComponent (with custom padding)
-  const CreateInputComponent: FC<TreeItemToolProps> = useMemo(() => {
-    const Component: FC<TreeItemToolProps> = ({ item }) => {
-      const handleCreate = async (value: string) => {
-        await create(item, value);
-      };
-
-      const handleCancel = () => {
+  // Create from placeholder node (used by onRename handler in SimplifiedItemsTree)
+  const createFromPlaceholder = useCallback(
+    async (
+      placeholderItem: ItemInstance<IPageForItem>,
+      pageName: string,
+    ): Promise<CreateResult> => {
+      const parentItem = placeholderItem.getParent();
+      if (parentItem == null) {
         cancelCreating();
         cancelCreating();
-      };
-
-      // Calculate indent based on item level (child should be indented one level more than parent)
-      const parentLevel = item.getItemMeta().level;
-      const childLevel = parentLevel + 1;
-      const indentSize = 10; // px, same as TreeItemLayout
+        return { success: false };
+      }
+      return create(parentItem, pageName);
+    },
+    [create, cancelCreating],
+  );
 
 
-      return (
-        <CreateInput
-          validateName={validateName}
-          onSubmit={handleCreate}
-          onCancel={handleCancel}
-          style={{ paddingLeft: `${childLevel * indentSize}px` }}
-        />
-      );
-    };
+  // CreateInput as alternative component for TreeItemLayout
+  // This uses item.getRenameInputProps() from headless-tree's renamingFeature
+  // Note: SimplifiedPageTreeItem ensures item.isRenaming() is true before rendering this
+  const CreateAlternativeComponent: FC<TreeItemToolProps> = useMemo(() => {
+    const Component: FC<TreeItemToolProps> = ({ item }) => (
+      <CreateInput
+        inputProps={item.getRenameInputProps()}
+        validateName={validateName}
+        className="flex-grow-1"
+      />
+    );
     return Component;
     return Component;
-  }, [create, cancelCreating, validateName]);
+  }, [validateName]);
 
 
   return {
   return {
     create,
     create,
+    createFromPlaceholder,
+    isCreatingPlaceholder,
     validateName,
     validateName,
     startCreating,
     startCreating,
     cancelCreating,
     cancelCreating,
     isCreatingChild,
     isCreatingChild,
     CreateAlternativeComponent,
     CreateAlternativeComponent,
-    CreateInputComponent,
   };
   };
 };
 };

+ 5 - 0
apps/app/src/features/page-tree/client/hooks/use-page-rename.tsx

@@ -17,6 +17,7 @@ import { mutatePageTree } from '~/stores/page-listing';
 
 
 import { RenameInput } from '../components/RenameInput';
 import { RenameInput } from '../components/RenameInput';
 import type { TreeItemToolProps } from '../interfaces';
 import type { TreeItemToolProps } from '../interfaces';
+import { CREATING_PAGE_VIRTUAL_ID } from '../states/page-tree-create';
 import { usePageTreeInformationUpdate } from '../states/page-tree-update';
 import { usePageTreeInformationUpdate } from '../states/page-tree-update';
 
 
 type RenameResult = {
 type RenameResult = {
@@ -68,6 +69,10 @@ export const usePageRename = (): UsePageRenameReturn => {
   const getPageName = useCallback(
   const getPageName = useCallback(
     (item: ItemInstance<IPageForItem>): string => {
     (item: ItemInstance<IPageForItem>): string => {
       const page = item.getItemData();
       const page = item.getItemData();
+      // Return empty string for placeholder node (new page creation)
+      if (page._id === CREATING_PAGE_VIRTUAL_ID) {
+        return '';
+      }
       return basename(page.path ?? '');
       return basename(page.path ?? '');
     },
     },
     [],
     [],