GrowiContextualSubNavigation.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import React, {
  2. useState, useCallback, useMemo, type JSX,
  3. } from 'react';
  4. import { isPopulated } from '@growi/core';
  5. import type {
  6. IPagePopulatedToShowRevision,
  7. IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
  8. } from '@growi/core';
  9. import { pagePathUtils } from '@growi/core/dist/utils';
  10. import { GlobalCodeMirrorEditorKey } from '@growi/editor';
  11. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  12. import { useAtomValue } from 'jotai';
  13. import { useTranslation } from 'next-i18next';
  14. import dynamic from 'next/dynamic';
  15. import Link from 'next/link';
  16. import { useRouter } from 'next/router';
  17. import Sticky from 'react-stickynode';
  18. import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
  19. import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
  20. import { usePrintMode } from '~/client/services/use-print-mode';
  21. import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
  22. import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
  23. import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
  24. import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
  25. import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
  26. import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
  27. import { useCurrentPathname, useCurrentUser } from '~/states/global';
  28. import { useCurrentPageId, useFetchCurrentPage } from '~/states/page';
  29. import { useShareLinkId } from '~/states/page/hooks';
  30. import {
  31. disableLinkSharingAtom,
  32. isBulkExportPagesEnabledAtom,
  33. isLocalAccountRegistrationEnabledAtom,
  34. isUploadEnabledAtom,
  35. } from '~/states/server-configurations';
  36. import { useDeviceLargerThanMd } from '~/states/ui/device';
  37. import { useEditorMode } from '~/states/ui/editor';
  38. import { PageAccessoriesModalContents, usePageAccessoriesModalActions } from '~/states/ui/modal/page-accessories';
  39. import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
  40. import { usePageDuplicateModalActions, type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
  41. import { usePresentationModalActions } from '~/states/ui/modal/page-presentation';
  42. import { usePageRenameModalActions } from '~/states/ui/modal/page-rename';
  43. import {
  44. useIsAbleToShowPageManagement,
  45. useIsAbleToChangeEditorMode,
  46. } from '~/states/ui/page-abilities';
  47. import {
  48. useSWRxPageInfo,
  49. } from '~/stores/page';
  50. import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
  51. import { CreateTemplateModalLazyLoaded } from '../CreateTemplateModal';
  52. import { NotAvailable } from '../NotAvailable';
  53. import { Skeleton } from '../Skeleton';
  54. import styles from './GrowiContextualSubNavigation.module.scss';
  55. import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
  56. const moduleClass = styles['grw-contextual-sub-navigation'];
  57. const minHeightSubNavigation = styles['grw-min-height-sub-navigation'];
  58. const PageEditorModeManager = dynamic(
  59. () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
  60. { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
  61. );
  62. const PageControls = dynamic(
  63. () => import('../PageControls').then(mod => mod.PageControls),
  64. { ssr: false, loading: () => <></> },
  65. );
  66. type PageOperationMenuItemsProps = {
  67. pageId: string,
  68. revisionId: string,
  69. isLinkSharingDisabled?: boolean,
  70. }
  71. const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
  72. const { t } = useTranslation();
  73. const {
  74. pageId, revisionId, isLinkSharingDisabled,
  75. } = props;
  76. const isGuestUser = useIsGuestUser();
  77. const isReadOnlyUser = useIsReadOnlyUser();
  78. const isSharedUser = useIsSharedUser();
  79. const isBulkExportPagesEnabled = useAtomValue(isBulkExportPagesEnabledAtom);
  80. const isUploadEnabled = useAtomValue(isUploadEnabledAtom);
  81. const { open: openPresentationModal } = usePresentationModalActions();
  82. const { open: openAccessoriesModal } = usePageAccessoriesModalActions();
  83. const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModalActions();
  84. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
  85. const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
  86. const syncLatestRevisionBodyHandler = useCallback(async () => {
  87. // eslint-disable-next-line no-alert
  88. const answer = window.confirm(t('sync-latest-revision-body.confirm'));
  89. if (answer) {
  90. try {
  91. const editingMarkdownLength = codeMirrorEditor?.getDoc().length;
  92. const res = await syncLatestRevisionBody(pageId, editingMarkdownLength);
  93. if (!res.synced) {
  94. toastWarning(t('sync-latest-revision-body.skipped-toaster'));
  95. return;
  96. }
  97. if (res?.isYjsDataBroken) {
  98. // eslint-disable-next-line no-alert
  99. window.alert(t('sync-latest-revision-body.alert'));
  100. return;
  101. }
  102. toastSuccess(t('sync-latest-revision-body.success-toaster'));
  103. }
  104. catch {
  105. toastError(t('sync-latest-revision-body.error-toaster'));
  106. }
  107. }
  108. }, [codeMirrorEditor, pageId, t]);
  109. return (
  110. <>
  111. <DropdownItem
  112. onClick={() => syncLatestRevisionBodyHandler()}
  113. className="grw-page-control-dropdown-item"
  114. >
  115. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
  116. {t('sync-latest-revision-body.menuitem')}
  117. </DropdownItem>
  118. {/* Presentation */}
  119. <DropdownItem
  120. onClick={() => openPresentationModal()}
  121. data-testid="open-presentation-modal-btn"
  122. className="grw-page-control-dropdown-item"
  123. >
  124. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">jamboard_kiosk</span>
  125. {t('Presentation Mode')}
  126. </DropdownItem>
  127. {/* Export markdown */}
  128. <DropdownItem
  129. onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
  130. className="grw-page-control-dropdown-item"
  131. >
  132. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
  133. {t('page_export.export_page_markdown')}
  134. </DropdownItem>
  135. {/* Bulk export */}
  136. {isBulkExportPagesEnabled && (
  137. <>
  138. <span id="bulkExportDropdownItem">
  139. <DropdownItem
  140. onClick={openPageBulkExportSelectModal}
  141. className="grw-page-control-dropdown-item"
  142. disabled={!isUploadEnabled ?? true}
  143. >
  144. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
  145. {t('page_export.bulk_export')}
  146. </DropdownItem>
  147. </span>
  148. <Tooltip
  149. placement={window.innerWidth < 800 ? 'bottom' : 'left'}
  150. isOpen={!isUploadEnabled && isBulkExportTooltipOpen}
  151. // Tooltip cannot be activated when target is disabled so set the target to wrapper span
  152. target="bulkExportDropdownItem"
  153. toggle={() => setIsBulkExportTooltipOpen(!isBulkExportTooltipOpen)}
  154. >
  155. {t('page_export.file_upload_not_configured')}
  156. </Tooltip>
  157. </>
  158. )}
  159. <DropdownItem divider />
  160. {/*
  161. TODO: show Tooltip when menu is disabled
  162. refs: PageAccessoriesModalControl
  163. */}
  164. <DropdownItem
  165. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
  166. disabled={!!isGuestUser || !!isSharedUser}
  167. data-testid="open-page-accessories-modal-btn-with-history-tab"
  168. className="grw-page-control-dropdown-item"
  169. >
  170. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">history</span>
  171. {t('History')}
  172. </DropdownItem>
  173. <DropdownItem
  174. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
  175. data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
  176. className="grw-page-control-dropdown-item"
  177. >
  178. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">attachment</span>
  179. {t('attachment_data')}
  180. </DropdownItem>
  181. {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
  182. <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
  183. <DropdownItem
  184. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
  185. data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
  186. className="grw-page-control-dropdown-item"
  187. >
  188. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">share</span>
  189. {t('share_links.share_link_management')}
  190. </DropdownItem>
  191. </NotAvailable>
  192. )}
  193. </>
  194. );
  195. };
  196. type CreateTemplateMenuItemsProps = {
  197. onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
  198. }
  199. const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
  200. const { t } = useTranslation();
  201. const { onClickTemplateMenuItem } = props;
  202. const openPageTemplateModalHandler = () => {
  203. onClickTemplateMenuItem(true);
  204. };
  205. return (
  206. <>
  207. {/* Create template */}
  208. <DropdownItem
  209. onClick={openPageTemplateModalHandler}
  210. className="grw-page-control-dropdown-item"
  211. data-testid="open-page-template-modal-btn"
  212. >
  213. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">contract_edit</span>
  214. {t('template.option_label.create/edit')}
  215. </DropdownItem>
  216. </>
  217. );
  218. };
  219. type GrowiContextualSubNavigationProps = {
  220. currentPage?: IPagePopulatedToShowRevision | null,
  221. };
  222. const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
  223. const { currentPage } = props;
  224. const { t } = useTranslation();
  225. const router = useRouter();
  226. const isPrinting = usePrintMode();
  227. const shareLinkId = useShareLinkId();
  228. const { fetchCurrentPage } = useFetchCurrentPage();
  229. const currentPathname = useCurrentPathname();
  230. const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
  231. const revision = currentPage?.revision;
  232. const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
  233. const { editorMode } = useEditorMode();
  234. const pageId = useCurrentPageId(true);
  235. const currentUser = useCurrentUser();
  236. const isGuestUser = useIsGuestUser();
  237. const isReadOnlyUser = useIsReadOnlyUser();
  238. const isLocalAccountRegistrationEnabled = useAtomValue(isLocalAccountRegistrationEnabledAtom);
  239. const isLinkSharingDisabled = useAtomValue(disableLinkSharingAtom);
  240. const isSharedUser = useIsSharedUser();
  241. const shouldExpandContent = useShouldExpandContent(currentPage);
  242. const isAbleToShowPageManagement = useIsAbleToShowPageManagement();
  243. const isAbleToChangeEditorMode = useIsAbleToChangeEditorMode();
  244. const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
  245. const { open: openDuplicateModal } = usePageDuplicateModalActions();
  246. const { open: openRenameModal } = usePageRenameModalActions();
  247. const { open: openDeleteModal } = usePageDeleteModalActions();
  248. const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
  249. const [isStickyActive, setStickyActive] = useState(false);
  250. const path = currentPage?.path ?? currentPathname;
  251. const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
  252. const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
  253. const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
  254. router.push(toPath);
  255. };
  256. openDuplicateModal(page, { onDuplicated: duplicatedHandler });
  257. }, [openDuplicateModal, router]);
  258. const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
  259. const renamedHandler: OnRenamedFunction = () => {
  260. fetchCurrentPage({ force: true });
  261. mutatePageInfo();
  262. mutatePageTree();
  263. mutateRecentlyUpdated();
  264. };
  265. openRenameModal(page, { onRenamed: renamedHandler });
  266. }, [fetchCurrentPage, mutatePageInfo, openRenameModal]);
  267. const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
  268. const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
  269. if (typeof pathOrPathsToDelete !== 'string') {
  270. return;
  271. }
  272. const path = pathOrPathsToDelete;
  273. if (isCompletely) {
  274. // redirect to NotFound Page
  275. router.push(path);
  276. }
  277. else if (currentPathname != null) {
  278. router.push(currentPathname);
  279. }
  280. fetchCurrentPage({ force: true });
  281. mutatePageInfo();
  282. mutatePageTree();
  283. mutateRecentlyUpdated();
  284. };
  285. openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
  286. }, [currentPathname, fetchCurrentPage, openDeleteModal, router, mutatePageInfo]);
  287. const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
  288. if (!isSharedPage) {
  289. await updateContentWidth(pageId, value);
  290. fetchCurrentPage({ force: true });
  291. }
  292. }, [isSharedPage, fetchCurrentPage]);
  293. const additionalMenuItemsRenderer = useCallback(() => {
  294. if (revisionId == null || pageId == null) {
  295. return (
  296. <>
  297. {!isReadOnlyUser
  298. && (
  299. <CreateTemplateMenuItems
  300. onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
  301. />
  302. )
  303. }
  304. </>
  305. );
  306. }
  307. return (
  308. <>
  309. <PageOperationMenuItems
  310. pageId={pageId}
  311. revisionId={revisionId}
  312. isLinkSharingDisabled={isLinkSharingDisabled}
  313. />
  314. {!isReadOnlyUser && (
  315. <>
  316. <DropdownItem divider />
  317. <CreateTemplateMenuItems
  318. onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
  319. />
  320. </>
  321. )
  322. }
  323. </>
  324. );
  325. }, [isLinkSharingDisabled, pageId, revisionId, isReadOnlyUser]);
  326. // hide sub controls when sticky on mobile device
  327. const hideSubControls = useMemo(() => {
  328. return !isDeviceLargerThanMd && isStickyActive;
  329. }, [isDeviceLargerThanMd, isStickyActive]);
  330. return (
  331. <>
  332. <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
  333. <Sticky
  334. className="z-1 position-fixed d-print-none"
  335. enabled={!isPrinting}
  336. innerActiveClass="w-100 end-0"
  337. >
  338. <GroundGlassBar className={minHeightSubNavigation} />
  339. </Sticky>
  340. <Sticky
  341. className="z-3"
  342. enabled={!isPrinting}
  343. onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
  344. innerActiveClass="w-100 end-0"
  345. >
  346. <nav
  347. className={`${moduleClass} ${minHeightSubNavigation}
  348. d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
  349. `}
  350. data-testid="grw-contextual-sub-nav"
  351. id="grw-contextual-sub-nav"
  352. >
  353. <PageControls
  354. pageId={pageId}
  355. revisionId={revisionId}
  356. shareLinkId={shareLinkId}
  357. path={path ?? currentPathname} // If the page is empty, "path" is undefined
  358. expandContentWidth={shouldExpandContent}
  359. disableSeenUserInfoPopover={isSharedUser}
  360. hideSubControls={hideSubControls}
  361. showPageControlDropdown={isAbleToShowPageManagement}
  362. additionalMenuItemRenderer={additionalMenuItemsRenderer}
  363. onClickDuplicateMenuItem={duplicateItemClickedHandler}
  364. onClickRenameMenuItem={renameItemClickedHandler}
  365. onClickDeleteMenuItem={deleteItemClickedHandler}
  366. onClickSwitchContentWidth={switchContentWidthHandler}
  367. />
  368. {isAbleToChangeEditorMode && (
  369. <PageEditorModeManager
  370. editorMode={editorMode}
  371. isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
  372. path={path}
  373. />
  374. )}
  375. {isGuestUser && (
  376. <div>
  377. <span>
  378. <span className="d-inline-block" id="sign-up-link">
  379. <Link
  380. href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
  381. className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
  382. style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
  383. prefetch={false}
  384. >
  385. <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
  386. </Link>
  387. </span>
  388. {!isLocalAccountRegistrationEnabled && (
  389. <UncontrolledTooltip target="sign-up-link" fade={false}>
  390. {t('tooltip.login_required')}
  391. </UncontrolledTooltip>
  392. )}
  393. </span>
  394. <Link href="/login#login" className="btn btn-primary" prefetch={false}>
  395. <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
  396. </Link>
  397. </div>
  398. )}
  399. </nav>
  400. </Sticky>
  401. {path != null && currentUser != null && !isReadOnlyUser && (
  402. <CreateTemplateModalLazyLoaded
  403. path={path}
  404. isOpen={isPageTemplateModalShown}
  405. onClose={() => setIsPageTempleteModalShown(false)}
  406. />
  407. )}
  408. </>
  409. );
  410. };
  411. export default GrowiContextualSubNavigation;