PagePathHeader.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import type { ChangeEvent, JSX } from 'react';
  2. import { memo, useCallback, useState } from 'react';
  3. import type { IPagePopulatedToShowRevision } from '@growi/core';
  4. import { DevidedPagePath } from '@growi/core/dist/models';
  5. import { normalizePath } from '@growi/core/dist/utils/path-utils';
  6. import { useTranslation } from 'next-i18next';
  7. import nodePath from 'path';
  8. import { debounce } from 'throttle-debounce';
  9. import type { InputValidationResult } from '~/client/util/use-input-validator';
  10. import {
  11. useInputValidator,
  12. ValidationTarget,
  13. } from '~/client/util/use-input-validator';
  14. import type { IPageForItem } from '~/interfaces/page';
  15. import LinkedPagePath from '~/models/linked-page-path';
  16. import { usePageSelectModalActions } from '~/states/ui/modal/page-select';
  17. import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
  18. import {
  19. AutosizeSubmittableInput,
  20. getAdjustedMaxWidthForAutosizeInput,
  21. } from '../Common/SubmittableInput';
  22. import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
  23. import styles from './PagePathHeader.module.scss';
  24. const moduleClass = styles['page-path-header'];
  25. type Props = {
  26. currentPage: IPagePopulatedToShowRevision;
  27. className?: string;
  28. maxWidth?: number;
  29. onRenameTerminated?: () => void;
  30. };
  31. export const PagePathHeader = memo((props: Props): JSX.Element => {
  32. const { t } = useTranslation();
  33. const { currentPage, className, maxWidth, onRenameTerminated } = props;
  34. const dPagePath = new DevidedPagePath(currentPage.path, true);
  35. const parentPagePath = dPagePath.former;
  36. const linkedPagePath = new LinkedPagePath(parentPagePath);
  37. const [isRenameInputShown, setRenameInputShown] = useState(false);
  38. const [isHover, setHover] = useState(false);
  39. const { open: openPageSelectModal } = usePageSelectModalActions();
  40. const [validationResult, setValidationResult] =
  41. useState<InputValidationResult>();
  42. const inputValidator = useInputValidator(ValidationTarget.PAGE);
  43. const changeHandler = useCallback(
  44. async (e: ChangeEvent<HTMLInputElement>) => {
  45. const validationResult = inputValidator(e.target.value);
  46. setValidationResult(validationResult ?? undefined);
  47. },
  48. [inputValidator],
  49. );
  50. const changeHandlerDebounced = debounce(300, changeHandler);
  51. const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
  52. const onClickOpenPageSelectModalButton = useCallback(() => {
  53. const onSelected = (page: IPageForItem): void => {
  54. if (page == null || page.path == null) {
  55. return;
  56. }
  57. const currentPageTitle =
  58. nodePath.basename(currentPage?.path ?? '') || '/';
  59. const newPagePath = nodePath.resolve(page.path, currentPageTitle);
  60. pagePathRenameHandler(newPagePath);
  61. };
  62. openPageSelectModal({ onSelected });
  63. }, [currentPage?.path, openPageSelectModal, pagePathRenameHandler]);
  64. const rename = useCallback(
  65. (inputText) => {
  66. const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
  67. pagePathRenameHandler(
  68. pathToRename,
  69. () => {
  70. setRenameInputShown(false);
  71. setValidationResult(undefined);
  72. onRenameTerminated?.();
  73. },
  74. () => {
  75. setRenameInputShown(true);
  76. },
  77. );
  78. },
  79. [dPagePath.latter, pagePathRenameHandler, onRenameTerminated],
  80. );
  81. const cancel = useCallback(() => {
  82. // reset
  83. setValidationResult(undefined);
  84. setRenameInputShown(false);
  85. }, []);
  86. const onClickEditButton = useCallback(() => {
  87. // reset
  88. setRenameInputShown(true);
  89. }, []);
  90. if (dPagePath.isRoot) {
  91. return <></>;
  92. }
  93. const isInvalid = validationResult != null;
  94. const fixedMaxWidth =
  95. maxWidth != null
  96. ? maxWidth - 60 // 60px is the width of the buttons
  97. : undefined;
  98. const inputMaxWidth =
  99. maxWidth != null
  100. ? getAdjustedMaxWidthForAutosizeInput(
  101. maxWidth,
  102. 'sm',
  103. validationResult != null ? false : undefined,
  104. ) - 16
  105. : undefined;
  106. return (
  107. <fieldset
  108. id="page-path-header"
  109. className={`d-flex ${moduleClass} ${className ?? ''} small position-relative ms-2 border-0 p-0 m-0`}
  110. aria-label="Page path header"
  111. onMouseEnter={() => setHover(true)}
  112. onMouseLeave={() => setHover(false)}
  113. onFocus={() => setHover(true)}
  114. onBlur={() => setHover(false)}
  115. >
  116. <div
  117. className="page-path-header-input d-inline-block"
  118. style={{ maxWidth: fixedMaxWidth }}
  119. >
  120. {isRenameInputShown && (
  121. <div className="position-relative">
  122. <div className="position-absolute w-100">
  123. <AutosizeSubmittableInput
  124. value={parentPagePath}
  125. inputClassName={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
  126. inputStyle={{ maxWidth: inputMaxWidth }}
  127. placeholder={t('Input parent page path')}
  128. onChange={changeHandlerDebounced}
  129. onSubmit={rename}
  130. onCancel={cancel}
  131. autoFocus
  132. />
  133. </div>
  134. </div>
  135. )}
  136. <div
  137. className={`${isRenameInputShown ? 'invisible' : ''} text-truncate`}
  138. >
  139. <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />
  140. </div>
  141. </div>
  142. <div
  143. className={`page-path-header-buttons d-flex align-items-center ms-2 ${isHover && !isRenameInputShown ? '' : 'invisible'}`}
  144. >
  145. <button
  146. type="button"
  147. className="btn btn-outline-neutral-secondary me-2 d-flex align-items-center justify-content-center"
  148. onClick={onClickEditButton}
  149. >
  150. <span className="material-symbols-outlined fs-6">edit</span>
  151. </button>
  152. <button
  153. type="button"
  154. className="btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center"
  155. onClick={onClickOpenPageSelectModalButton}
  156. >
  157. <span className="material-symbols-outlined fs-6">account_tree</span>
  158. </button>
  159. </div>
  160. </fieldset>
  161. );
  162. });