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

Merge pull request #8439 from weseek/imprv/140068-improve-PageTitleHeader

imprv: improve PageHeader component
WNomunomu 2 лет назад
Родитель
Сommit
c067c05d0f

+ 11 - 2
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,16 +1,19 @@
+import type { FC } from 'react';
 import React, {
-  FC, memo, useEffect, useRef, useState,
+  memo, useEffect, useRef, useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
+import type { AlertInfo } from '~/client/util/input-validator';
+import { AlertType, inputValidator } from '~/client/util/input-validator';
 
 type ClosableTextInputProps = {
   value?: string
   placeholder?: string
   validationTarget?: string,
   onPressEnter?(inputText: string | null): void
+  onPressEscape?: () => void
   onClickOutside?(): void
   handleInputChange?: (string) => void
 }
@@ -66,6 +69,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         }
         onPressEnter();
         break;
+      case 'Escape':
+        if (isComposing) {
+          return;
+        }
+        props.onPressEscape?.();
+        break;
       default:
         break;
     }

+ 4 - 6
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,26 +1,24 @@
-import { FC } from 'react';
+import type { FC } from 'react';
 
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
+
 export const PageHeader: FC = () => {
-  const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPage } = useSWRxCurrentPage();
 
-  if (currentPage == null || currentPagePath == null) {
+  if (currentPage == null) {
     return <></>;
   }
 
   return (
     <>
       <PagePathHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
       />
       <PageTitleHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
       />
     </>

+ 86 - 69
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,84 +1,95 @@
 import {
-  FC, useEffect, useMemo, useState,
+  useMemo, useState, useEffect, useCallback,
 } from 'react';
+import type { FC } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 
+import { ValidationTarget } from '~/client/util/input-validator';
 import { usePageSelectModal } from '~/stores/modal';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
+import ClosableTextInput from '../Common/ClosableTextInput';
 import { PagePathNav } from '../Common/PagePathNav';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
 import { usePagePathRenameHandler } from './page-header-utils';
 
-type Props = {
-  currentPagePath: string
+
+export type Props = {
   currentPage: IPagePopulatedToShowRevision
 }
 
 export const PagePathHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isButtonsShown, setButtonShown] = useState(false);
-  const [inputText, setInputText] = useState('');
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
 
   const { data: editorMode } = useEditorMode();
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
 
-  const onRenameFinish = () => {
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const { t } = useTranslation();
+
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
-  };
+  }, []);
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
-  };
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    setEditedPagePath(inputText);
+  }, []);
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
 
-  const isOpened = PageSelectModalData?.isOpened ?? false;
+  const onClickEditButton = useCallback(() => {
+    if (isRenameInputShown) {
+      pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+    }
+    else {
+      setEditedPagePath(currentPagePath);
+      setRenameInputShown(true);
+    }
+  }, [currentPagePath, editedPagePath, isRenameInputShown, pagePathRenameHandler]);
 
+  const isOpened = PageSelectModalData?.isOpened ?? false;
   const isViewMode = editorMode === EditorMode.View;
   const isEditorMode = !isViewMode;
 
   const PagePath = useMemo(() => (
-    <>
-      {currentPagePath != null && (
-        <PagePathNav
-          pageId={currentPage._id}
-          pagePath={currentPagePath}
-          isSingleLineMode={isEditorMode}
-        />
-      )}
-    </>
+    <PagePathNav
+      pageId={currentPage._id}
+      pagePath={currentPagePath}
+      isSingleLineMode={isEditorMode}
+    />
   ), [currentPage._id, currentPagePath, isEditorMode]);
 
-  const handleInputChange = (inputText: string) => {
-    setInputText(inputText);
-  };
-
-  const handleEditButtonClick = () => {
-    if (isRenameInputShown) {
-      pagePathRenameHandler(inputText);
-    }
-    else {
-      setRenameInputShown(true);
-    }
-  };
 
   const buttonStyle = isButtonsShown ? '' : 'd-none';
 
-  const clickOutSideHandler = (e) => {
+  const clickOutSideHandler = useCallback((e) => {
     const container = document.getElementById('page-path-header');
 
     if (container && !container.contains(e.target)) {
       setRenameInputShown(false);
     }
-  };
+  }, []);
 
   useEffect(() => {
     document.addEventListener('click', clickOutSideHandler);
@@ -88,43 +99,49 @@ export const PagePathHeader: FC<Props> = (props) => {
     };
   }, []);
 
+
   return (
-    <>
-      <div
-        id="page-path-header"
-        onMouseLeave={() => setButtonShown(false)}
-      >
-        <div className="row">
-          <div
-            className="col-4"
-            onMouseEnter={() => setButtonShown(true)}
-          >
-            <TextInputForPageTitleAndPath
-              currentPage={currentPage}
-              stateHandler={stateHandler}
-              inputValue={currentPagePath}
-              CustomComponent={PagePath}
-              handleInputChange={handleInputChange}
-            />
-          </div>
-          <div className={`${buttonStyle} col-4 row`}>
-            <div className="col-4">
-              <button type="button" onClick={handleEditButtonClick}>
-                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
-              </button>
-            </div>
-            <div className="col-4">
-              <button type="button" onClick={openPageSelectModal}>
-                <span className="material-symbols-outlined">account_tree</span>
-              </button>
+    <div
+      id="page-path-header"
+      onMouseLeave={() => setButtonShown(false)}
+    >
+      <div className="row">
+        <div
+          className="col-4"
+          onMouseEnter={() => setButtonShown(true)}
+        >
+          {isRenameInputShown ? (
+            <div className="flex-fill">
+              <ClosableTextInput
+                value={editedPagePath}
+                placeholder={t('Input page name')}
+                onPressEnter={onPressEnter}
+                onPressEscape={onPressEscape}
+                validationTarget={ValidationTarget.PAGE}
+                handleInputChange={onInputChange}
+              />
             </div>
+          ) : (
+            <>{ PagePath }</>
+          )}
+        </div>
+        <div className={`${buttonStyle} col-4 row`}>
+          <div className="col-4">
+            <button type="button" onClick={onClickEditButton}>
+              {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
+            </button>
+          </div>
+          <div className="col-4">
+            <button type="button" onClick={openPageSelectModal}>
+              <span className="material-symbols-outlined">account_tree</span>
+            </button>
           </div>
-          {isOpened
-            && (
-              <PageSelectModal />
-            )}
         </div>
+        {isOpened
+          && (
+            <PageSelectModal />
+          )}
       </div>
-    </>
+    </div>
   );
 };

+ 78 - 18
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,35 +1,95 @@
-import { FC, useState, useMemo } from 'react';
+import type { FC } from 'react';
+import { useState, useCallback } from 'react';
 
 import nodePath from 'path';
 
-import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { ValidationTarget } from '~/client/util/input-validator';
 
-type Props = {
-  currentPagePath: string,
-  currentPage: IPagePopulatedToShowRevision;
-}
+import ClosableTextInput from '../Common/ClosableTextInput';
+
+import type { Props } from './PagePathHeader';
+import { usePagePathRenameHandler } from './page-header-utils';
 
 
 export const PageTitleHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
+
+  const pageTitle = nodePath.basename(currentPagePath) || '/';
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const { t } = useTranslation();
+
+  const editedPageTitle = nodePath.basename(editedPagePath);
+
+  const onRenameFinish = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const onRenameFailure = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
+    const newPagePath = nodePath.resolve(parentPagePath, inputText);
+
+    setEditedPagePath(newPagePath);
+  }, [currentPage?.path, setEditedPagePath]);
+
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
+
+  const onClickButton = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onClickPageTitle = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(true);
+  }, [currentPagePath]);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const PageTitle = <div onClick={onClickPageTitle}>{pageTitle}</div>;
 
-  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
+  const buttonStyle = isRenameInputShown ? '' : 'd-none';
 
   return (
-    <div onBlur={() => setRenameInputShown(false)}>
-      <TextInputForPageTitleAndPath
-        currentPage={currentPage}
-        stateHandler={stateHandler}
-        inputValue={pageName}
-        CustomComponent={PageTitle}
-      />
+    <div className="row">
+      <div className="col-4">
+        {isRenameInputShown ? (
+          <div className="flex-fill">
+            <ClosableTextInput
+              value={editedPageTitle}
+              placeholder={t('Input page name')}
+              onPressEnter={onPressEnter}
+              onPressEscape={onPressEscape}
+              validationTarget={ValidationTarget.PAGE}
+              handleInputChange={onInputChange}
+            />
+          </div>
+        ) : (
+          <>{ PageTitle }</>
+        )}
+      </div>
+      <div className={`col-4 ${buttonStyle}`}>
+        <button type="button" onClick={onClickButton}>
+          <span className="material-symbols-outlined">check_circle</span>
+        </button>
+      </div>
     </div>
   );
 };

+ 0 - 76
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -1,76 +0,0 @@
-import { FC, useCallback } from 'react';
-import type { Dispatch, SetStateAction } from 'react';
-
-import nodePath from 'path';
-
-import type { IPagePopulatedToShowRevision } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { ValidationTarget } from '~/client/util/input-validator';
-
-import ClosableTextInput from '../Common/ClosableTextInput';
-
-
-import { usePagePathRenameHandler } from './page-header-utils';
-
-
-type StateHandler = {
-  isRenameInputShown: boolean
-  setRenameInputShown: Dispatch<SetStateAction<boolean>>
-}
-
-type Props = {
-  currentPage: IPagePopulatedToShowRevision
-  stateHandler: StateHandler
-  inputValue: string
-  CustomComponent: JSX.Element
-  handleInputChange?: (string) => void
-}
-
-export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
-  const {
-    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
-  } = props;
-
-  const { t } = useTranslation();
-
-  const { isRenameInputShown, setRenameInputShown } = stateHandler;
-
-  const onRenameFinish = () => {
-    setRenameInputShown(false);
-  };
-
-  const onRenameFailure = () => {
-    setRenameInputShown(true);
-  };
-
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
-
-  const onPressEnter = useCallback((inputPagePath: string) => {
-
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
-
-    pagePathRenameHandler(newPagePath);
-
-  }, [currentPage.path, pagePathRenameHandler]);
-
-  return (
-    <>
-      {isRenameInputShown ? (
-        <div className="flex-fill">
-          <ClosableTextInput
-            value={inputValue}
-            placeholder={t('Input page name')}
-            onPressEnter={onPressEnter}
-            validationTarget={ValidationTarget.PAGE}
-            handleInputChange={handleInputChange}
-          />
-        </div>
-      ) : (
-        <>{ CustomComponent }</>
-      )}
-    </>
-  );
-};

+ 7 - 5
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -8,16 +8,18 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
 export const usePagePathRenameHandler = (
-    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
-): (newPagePath: string) => Promise<void> => {
+    currentPage: IPagePopulatedToShowRevision,
+): PagePathRenameHandler => {
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
 
   const currentPagePath = currentPage.path;
 
-  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+  const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
@@ -34,7 +36,6 @@ export const usePagePathRenameHandler = (
     }
 
     try {
-      onRenameFinish?.();
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
         revisionId: currentPage.revision._id,
@@ -42,6 +43,7 @@ export const usePagePathRenameHandler = (
       });
 
       onRenamed(currentPage.path, newPagePath);
+      onRenameFinish?.();
 
       toastSuccess(t('renamed_pages', { path: currentPage.path }));
     }
@@ -49,7 +51,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, t]);
 
   return pagePathRenameHandler;
 };