PageTitleHeader.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import type { ChangeEvent, JSX } from 'react';
  2. import {
  3. useState, useCallback, useEffect, useMemo,
  4. } from 'react';
  5. import nodePath from 'path';
  6. import type { IPagePopulatedToShowRevision } from '@growi/core';
  7. import { DevidedPagePath } from '@growi/core/dist/models';
  8. import { pathUtils } from '@growi/core/dist/utils';
  9. import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
  10. import { useTranslation } from 'next-i18next';
  11. import type { InputValidationResult } from '~/client/util/use-input-validator';
  12. import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
  13. import { EditorMode, useEditorMode } from '~/stores-universal/ui';
  14. import { useIsUntitledPage } from '~/stores/ui';
  15. import { CopyDropdown } from '../Common/CopyDropdown';
  16. import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
  17. import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
  18. import styles from './PageTitleHeader.module.scss';
  19. const moduleClass = styles['page-title-header'] ?? '';
  20. const borderColorClass = styles['page-title-header-border-color'] ?? '';
  21. type Props = {
  22. currentPage: IPagePopulatedToShowRevision,
  23. className?: string,
  24. maxWidth?: number,
  25. onMoveTerminated?: () => void,
  26. };
  27. export const PageTitleHeader = (props: Props): JSX.Element => {
  28. const { t } = useTranslation();
  29. const { currentPage, maxWidth, onMoveTerminated } = props;
  30. const currentPagePath = currentPage.path;
  31. const isMovable = isMovablePage(currentPagePath);
  32. const dPagePath = new DevidedPagePath(currentPage.path, true);
  33. const pageTitle = dPagePath.latter;
  34. const [isRenameInputShown, setRenameInputShown] = useState(false);
  35. const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
  36. const [validationResult, setValidationResult] = useState<InputValidationResult>();
  37. const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
  38. const inputValidator = useInputValidator(ValidationTarget.PAGE);
  39. const editedPageTitle = nodePath.basename(editedPagePath);
  40. const { data: editorMode } = useEditorMode();
  41. const { data: isUntitledPage } = useIsUntitledPage();
  42. const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
  43. const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
  44. const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
  45. const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
  46. setEditedPagePath(newPagePath);
  47. // validation
  48. const validationResult = inputValidator(e.target.value);
  49. setValidationResult(validationResult ?? undefined);
  50. }, [currentPage.path, inputValidator]);
  51. const rename = useCallback(() => {
  52. pagePathRenameHandler(editedPagePath,
  53. () => {
  54. setRenameInputShown(false);
  55. setValidationResult(undefined);
  56. onMoveTerminated?.();
  57. },
  58. () => {
  59. setRenameInputShown(true);
  60. });
  61. }, [editedPagePath, onMoveTerminated, pagePathRenameHandler]);
  62. const cancel = useCallback(() => {
  63. setEditedPagePath(currentPagePath);
  64. setValidationResult(undefined);
  65. setRenameInputShown(false);
  66. }, [currentPagePath]);
  67. const onClickPageTitle = useCallback(() => {
  68. if (!isMovable) {
  69. return;
  70. }
  71. setEditedPagePath(currentPagePath);
  72. setRenameInputShown(true);
  73. }, [currentPagePath, isMovable]);
  74. useEffect(() => {
  75. setEditedPagePath(currentPagePath);
  76. if (isUntitledPage != null) {
  77. setRenameInputShown(isUntitledPage && editorMode === EditorMode.Editor);
  78. }
  79. }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
  80. const isInvalid = validationResult != null;
  81. // calculate inputMaxWidth as the maximum width of AutoSizeInput minus the width of WIP badge and CopyDropdown
  82. const inputMaxWidth = useMemo(() => {
  83. if (maxWidth == null) {
  84. return undefined;
  85. }
  86. const wipBadgeAndCopyDropdownWidth = 4 // me-1
  87. + (currentPage.wip ? 49 : 0) // WIP badge + gap
  88. + 24; // CopyDropdown
  89. return getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - wipBadgeAndCopyDropdownWidth;
  90. }, [currentPage.wip, maxWidth, validationResult]);
  91. // calculate h1MaxWidth as the inputMaxWidth plus padding
  92. const h1MaxWidth = useMemo(() => {
  93. if (inputMaxWidth == null) {
  94. return undefined;
  95. }
  96. return inputMaxWidth + 16; // plus the padding of px-2 because AutosizeInput has "box-sizing: content-box;"
  97. }, [inputMaxWidth]);
  98. return (
  99. <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
  100. <div className="page-title-header-input me-1 d-inline-block">
  101. { isRenameInputShown && (
  102. <div className="position-relative">
  103. <div className="position-absolute w-100">
  104. <AutosizeSubmittableInput
  105. value={isUntitledPage ? '' : editedPageTitle}
  106. inputClassName={`form-control fs-4 ${isInvalid ? 'is-invalid' : ''}`}
  107. inputStyle={{ maxWidth: inputMaxWidth }}
  108. placeholder={t('Input page name')}
  109. onChange={changeHandler}
  110. onSubmit={rename}
  111. onCancel={cancel}
  112. autoFocus
  113. />
  114. </div>
  115. </div>
  116. ) }
  117. <h1
  118. className={`mb-0 mb-sm-1 px-2 fs-4
  119. ${isRenameInputShown ? 'invisible' : ''} text-truncate
  120. ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
  121. `}
  122. style={{ maxWidth: h1MaxWidth }}
  123. onClick={onClickPageTitle}
  124. >
  125. {pageTitle}
  126. </h1>
  127. </div>
  128. <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}>
  129. { currentPage.wip && (
  130. <span className="badge rounded-pill text-bg-secondary">WIP</span>
  131. )}
  132. <CopyDropdown
  133. pageId={currentPage._id}
  134. pagePath={currentPage.path}
  135. dropdownToggleId={`copydropdown-in-pagetitleheader-${currentPage._id}`}
  136. dropdownToggleClassName="p-1"
  137. >
  138. <span className="material-symbols-outlined fs-6">content_paste</span>
  139. </CopyDropdown>
  140. </div>
  141. </div>
  142. );
  143. };