2
0

GrowiContextualSubNavigation.tsx 17 KB

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