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

Merge pull request #7296 from weseek/master

Release v6.0.5
Yuki Takei 3 лет назад
Родитель
Сommit
409510eaca
47 измененных файлов с 902 добавлено и 594 удалено
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 12 11
      packages/app/package.json
  4. 89 0
      packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  5. 33 0
      packages/app/src/client/services/side-effects/hackmd-draft-updated.ts
  6. 89 0
      packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  7. 3 6
      packages/app/src/client/services/side-effects/hash-changed.ts
  8. 39 0
      packages/app/src/client/services/side-effects/page-updated.ts
  9. 11 5
      packages/app/src/components/Comments.tsx
  10. 37 0
      packages/app/src/components/Common/LazyRenderer.tsx
  11. 7 22
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  12. 11 1
      packages/app/src/components/DescendantsPageListModal.tsx
  13. 1 1
      packages/app/src/components/Layout/MainPane.tsx
  14. 10 4
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  15. 0 249
      packages/app/src/components/Page.tsx
  16. 30 132
      packages/app/src/components/Page/DisplaySwitcher.tsx
  17. 102 0
      packages/app/src/components/Page/PageContents.tsx
  18. 0 0
      packages/app/src/components/Page/PageView.module.scss
  19. 129 0
      packages/app/src/components/Page/PageView.tsx
  20. 1 1
      packages/app/src/components/Page/RevisionRenderer.tsx
  21. 0 1
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  22. 64 0
      packages/app/src/components/ShareLink/ShareLinkPageContents.tsx
  23. 0 2
      packages/app/src/components/TableOfContents.tsx
  24. 30 67
      packages/app/src/pages/[[...path]].page.tsx
  25. 0 6
      packages/app/src/pages/_document.page.tsx
  26. 26 4
      packages/app/src/pages/share/[[...path]].page.tsx
  27. 15 2
      packages/app/src/pages/utils/commons.ts
  28. 1 1
      packages/app/src/server/routes/apiv3/pages.js
  29. 24 0
      packages/app/src/server/service/page.ts
  30. 1 0
      packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts
  31. 60 2
      packages/app/src/services/renderer/renderer.tsx
  32. 6 2
      packages/app/src/stores/editor.tsx
  33. 6 2
      packages/app/src/stores/personal-settings.tsx
  34. 4 1
      packages/app/src/stores/renderer.tsx
  35. 28 50
      packages/app/src/styles/_mixins.scss
  36. 6 2
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts
  37. 10 3
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  38. 1 1
      packages/codemirror-textlint/package.json
  39. 1 1
      packages/core/package.json
  40. 1 1
      packages/hackmd/package.json
  41. 1 1
      packages/preset-themes/package.json
  42. 1 1
      packages/remark-drawio/package.json
  43. 1 1
      packages/remark-growi-directive/package.json
  44. 4 4
      packages/remark-lsx/package.json
  45. 1 1
      packages/slack/package.json
  46. 2 2
      packages/slackbot-proxy/package.json
  47. 2 2
      packages/ui/package.json

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 12 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -55,7 +55,8 @@
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "next": "/Sandbox rendering is crashed with v12.3 or above ",
     "string-width": "5.0.0 or above exports only ESM.",
-    "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster."
+    "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster.",
+    "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
@@ -66,14 +67,14 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.4",
-    "@growi/core": "^6.0.4",
-    "@growi/hackmd": "^6.0.4",
-    "@growi/preset-themes": "^6.0.4",
-    "@growi/remark-drawio": "^6.0.4",
-    "@growi/remark-growi-directive": "^6.0.4",
-    "@growi/remark-lsx": "^6.0.4",
-    "@growi/slack": "^6.0.4",
+    "@growi/codemirror-textlint": "^6.0.5-RC.0",
+    "@growi/core": "^6.0.5-RC.0",
+    "@growi/hackmd": "^6.0.5-RC.0",
+    "@growi/preset-themes": "^6.0.5-RC.0",
+    "@growi/remark-drawio": "^6.0.5-RC.0",
+    "@growi/remark-growi-directive": "^6.0.5-RC.0",
+    "@growi/remark-lsx": "^6.0.5-RC.0",
+    "@growi/slack": "^6.0.5-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -202,7 +203,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^6.0.4",
+    "@growi/ui": "^6.0.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 89 - 0
packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -0,0 +1,89 @@
+import { useCallback, useEffect } from 'react';
+
+import EventEmitter from 'events';
+
+import { DrawioEditByViewerProps } from '@growi/remark-drawio';
+
+import { useSaveOrUpdate } from '~/client/services/page-operation';
+import mdu from '~/components/PageEditor/MarkdownDrawioUtil';
+import type { OptionsToSave } from '~/interfaces/page-operation';
+import { useShareLinkId } from '~/stores/context';
+import { useDrawioModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const useDrawioModalLauncherForView = (opts?: {
+  onSaveSuccess?: (newMarkdown: string) => void,
+  onSaveError?: (error: any) => void,
+}): void => {
+
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
+
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      opts?.onSaveSuccess?.(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      opts?.onSaveError?.(error);
+    }
+  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+
+
+  // set handler to open DrawioModal
+  useEffect(() => {
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const handler = (data: DrawioEditByViewerProps) => {
+      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+    };
+    globalEmitter.on('launchDrawioModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchDrawioModal', handler);
+    };
+  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
+};

+ 33 - 0
packages/app/src/client/services/side-effects/hackmd-draft-updated.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageId } from '~/stores/context';
+import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
+import { useGlobalSocket } from '~/stores/websocket';
+
+export const useHackmdDraftUpdatedEffect = (): void => {
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
+
+  const { data: socket } = useGlobalSocket();
+
+  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+    if (s2cMessagePageUpdated.pageId === currentPageId) {
+      mutateIsHackmdDraftUpdatingInRealtime(true);
+    }
+  }, [currentPageId, mutateIsHackmdDraftUpdatingInRealtime]);
+
+  // listen socket for hackmd saved
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+
+    return () => {
+      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+    };
+  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
+};

+ 89 - 0
packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -0,0 +1,89 @@
+import { useCallback, useEffect } from 'react';
+
+import EventEmitter from 'events';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
+import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import type { OptionsToSave } from '~/interfaces/page-operation';
+import { useShareLinkId } from '~/stores/context';
+import { useHandsontableModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const useHandsontableModalLauncherForView = (opts?: {
+  onSaveSuccess?: (newMarkdown: string) => void,
+  onSaveError?: (error: any) => void,
+}): void => {
+
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+
+  const { open: openHandsontableModal } = useHandsontableModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      opts?.onSaveSuccess?.(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      opts?.onSaveError?.(error);
+    }
+  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+
+
+  // set handler to open HandsonTableModal
+  useEffect(() => {
+    if (currentPage == null || shareLinkId != null) {
+      return;
+    }
+
+    const handler = (bol: number, eol: number) => {
+      const markdown = currentPage.revision.body;
+      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+    };
+    globalEmitter.on('launchHandsonTableModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchHandsonTableModal', handler);
+    };
+  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+};

+ 3 - 6
packages/app/src/components/EventListeneres/HashChanged.tsx → packages/app/src/client/services/side-effects/hash-changed.ts

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import { useCallback, useEffect } from 'react';
 
 import { useRouter } from 'next/router';
 
@@ -8,8 +8,9 @@ import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged = (): JSX.Element => {
+export const useHashChangedEffect = (): void => {
   const router = useRouter();
+
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -47,8 +48,4 @@ const HashChanged = (): JSX.Element => {
       router.events.off('routeChangeComplete', hashchangeHandler);
     };
   }, [hashchangeHandler, router.events]);
-
-  return <></>;
 };
-
-export default HashChanged;

+ 39 - 0
packages/app/src/client/services/side-effects/page-updated.ts

@@ -0,0 +1,39 @@
+import { useCallback, useEffect } from 'react';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { useGlobalSocket } from '~/stores/websocket';
+
+export const usePageUpdatedEffect = (): void => {
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  const { data: socket } = useGlobalSocket();
+
+  const setLatestRemotePageData = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const remoteData = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
+      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
+    };
+    setRemoteLatestPageData(remoteData);
+  }, [setRemoteLatestPageData]);
+
+  // listen socket for someone updating this page
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+    };
+
+  }, [setLatestRemotePageData, socket]);
+};

+ 11 - 5
packages/app/src/components/Comments.tsx

@@ -1,20 +1,24 @@
 import React from 'react';
 
-import { IRevisionHasId } from '@growi/core';
+import { type IRevisionHasId, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import { PageCommentProps } from '~/components/PageComment';
+import type { PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
-import { CommentEditorProps } from './PageComment/CommentEditor';
+import type { CommentEditorProps } from './PageComment/CommentEditor';
+
+
+const { isTopPage } = pagePathUtils;
+
 
 const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
-type CommentsProps = {
+export type CommentsProps = {
   pageId: string,
   pagePath: string,
   revision: IRevisionHasId,
@@ -28,7 +32,9 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   const { data: isDeleted } = useIsTrashPage();
   const { data: currentUser } = useCurrentUser();
 
-  if (pageId == null) {
+  const isTopPagePath = isTopPage(pagePath);
+
+  if (pageId == null || isTopPagePath) {
     return <></>;
   }
 

+ 37 - 0
packages/app/src/components/Common/LazyRenderer.tsx

@@ -0,0 +1,37 @@
+import React, { useEffect, useState } from 'react';
+
+type Props = {
+  shouldRender: boolean | (() => boolean),
+  children: JSX.Element,
+}
+
+export const LazyRenderer = (props: Props): JSX.Element => {
+  const { shouldRender: _shouldRender, children } = props;
+
+  const [isActivated, setActivated] = useState(false);
+
+  const shouldRender = typeof _shouldRender === 'function'
+    ? _shouldRender()
+    : _shouldRender;
+
+  useEffect(() => {
+    if (isActivated) {
+      return;
+    }
+    setActivated(shouldRender);
+  }, [isActivated, shouldRender]);
+
+  const additionalClassName = shouldRender ? '' : 'd-none';
+
+  if (!isActivated) {
+    return <></>;
+  }
+
+  return (
+    <>
+      { React.cloneElement(children, {
+        className: `${children.props.className ?? ''} ${additionalClassName}`,
+      }) }
+    </>
+  );
+};

+ 7 - 22
packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,41 +1,35 @@
-import React, { useEffect, useState } from 'react';
+import React from 'react';
 
-import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
 } from 'reactstrap';
 
-import { ICustomNavTabMappings } from '~/interfaces/ui';
+import type { ICustomNavTabMappings } from '~/interfaces/ui';
+
+import { LazyRenderer } from '../Common/LazyRenderer';
 
 
 type Props = {
   activeTab: string,
   navTabMapping: ICustomNavTabMappings,
   additionalClassNames?: string[],
-
 }
 
 const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
-  const [activatedContent, setActivatedContent] = useState(new Set([activeTab]));
-
-  // add activated content to Set
-  useEffect(() => {
-    setActivatedContent(activatedContent.add(activeTab));
-  }, [activatedContent, activeTab]);
-
   return (
     <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
       {Object.entries(navTabMapping).map(([key, value]) => {
 
-        const shouldRender = key === activeTab || activatedContent.has(key);
         const { Content } = value;
 
         return (
           <TabPane key={key} tabId={key}>
-            { shouldRender && <Content /> }
+            <LazyRenderer shouldRender={key === activeTab}>
+              <Content />
+            </LazyRenderer>
           </TabPane>
         );
       })}
@@ -44,13 +38,4 @@ const CustomTabContent = (props: Props): JSX.Element => {
 
 };
 
-CustomTabContent.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
-};
-CustomTabContent.defaultProps = {
-  additionalClassNames: [],
-};
-
 export default CustomTabContent;

+ 11 - 1
packages/app/src/components/DescendantsPageListModal.tsx

@@ -1,8 +1,9 @@
 
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
@@ -39,6 +40,15 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { data: status, close } = useDescendantsPageListModal();
 
+  const { events } = useRouter();
+
+  useEffect(() => {
+    events.on('routeChangeStart', close);
+    return () => {
+      events.off('routeChangeStart', close);
+    };
+  }, [close, events]);
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {

+ 1 - 1
packages/app/src/components/Layout/MainPane.tsx

@@ -20,7 +20,7 @@ export const MainPane = (props: Props): JSX.Element => {
           <div id="content-main" className="content-main grw-container-convertible">
             { sideContents != null
               ? (
-                <div className="d-flex flex-column flex-lg-row">
+                <div className="d-flex flex-column flex-column-reverse flex-lg-row">
                   <div className="flex-grow-1 flex-basis-0 mw-0">
                     {children}
                   </div>

+ 10 - 4
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -38,9 +38,11 @@ export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProp
   // use more specific type HTMLDivElement for avoid assertion error.
   // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement
   const fixedContainerRef = useRef<HTMLDivElement>(null);
+  const clientWidth = fixedContainerRef.current?.parentElement?.clientWidth;
 
+  // Do not use clientWidth as useCallback deps, resizing events will not work in production builds.
   const initWidth = useCallback(() => {
-    if (fixedContainerRef.current && fixedContainerRef.current.parentElement) {
+    if (fixedContainerRef.current != null && fixedContainerRef.current.parentElement != null) {
       // get parent elements width
       const { clientWidth } = fixedContainerRef.current.parentElement;
       setWidth(clientWidth);
@@ -84,10 +86,14 @@ export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProp
     }
   }, [isSidebarCollapsed, initWidth]);
 
-  // initialize width
+  /*
+   * initialize width.
+   * Since width is not recalculated at production build first rendering,
+   * make initWidth execution dependent on clientWidth.
+   */
   useEffect(() => {
-    initWidth();
-  }, [initWidth]);
+    if (clientWidth != null) initWidth();
+  }, [initWidth, clientWidth]);
 
   if (currentPage == null) {
     return <></>;

+ 0 - 249
packages/app/src/components/Page.tsx

@@ -1,249 +0,0 @@
-import React, {
-  FC, useCallback,
-  useEffect, useRef,
-} from 'react';
-
-import EventEmitter from 'events';
-
-import { pagePathUtils, IPagePopulatedToShowRevision } from '@growi/core';
-import { DrawioEditByViewerProps } from '@growi/remark-drawio';
-import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import { HtmlElementNode } from 'rehype-toc';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { OptionsToSave } from '~/interfaces/page-operation';
-import {
-  useIsGuestUser, useShareLinkId, useCurrentPathname,
-} from '~/stores/context';
-import { useEditingMarkdown } from '~/stores/editor';
-import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useViewOptions } from '~/stores/renderer';
-import {
-  useCurrentPageTocNode,
-  useIsMobile,
-} from '~/stores/ui';
-import { registerGrowiFacade } from '~/utils/growi-facade';
-import loggerFactory from '~/utils/logger';
-
-import RevisionRenderer from './Page/RevisionRenderer';
-import mdu from './PageEditor/MarkdownDrawioUtil';
-import mtu from './PageEditor/MarkdownTableUtil';
-
-import styles from './Page.module.scss';
-
-
-declare global {
-  // eslint-disable-next-line vars-on-top, no-var
-  var globalEmitter: EventEmitter;
-}
-
-// const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
-const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
-
-
-const logger = loggerFactory('growi:Page');
-
-type Props = {
-  currentPage?: IPagePopulatedToShowRevision,
-}
-
-export const Page: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
-  const { currentPage } = props;
-
-  // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
-  // The toc node passed by customizeTOC is assigned to tocRef.current.
-  const tocRef = useRef<HtmlElementNode>();
-
-  const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
-    tocRef.current = toc;
-  }, []);
-
-  const { data: currentPathname } = useCurrentPathname();
-  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
-
-  const { data: shareLinkId } = useShareLinkId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isMobile } = useIsMobile();
-  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
-  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
-  const { open: openDrawioModal } = useDrawioModal();
-  const { open: openHandsontableModal } = useHandsontableModal();
-
-  const saveOrUpdate = useSaveOrUpdate();
-
-
-  // register to facade
-  useEffect(() => {
-    registerGrowiFacade({
-      markdownRenderer: {
-        optionsMutators: {
-          viewOptionsMutator: mutateRendererOptions,
-        },
-      },
-    });
-  }, [mutateRendererOptions]);
-
-  useEffect(() => {
-    mutateCurrentPageTocNode(tocRef.current);
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
-
-
-  // TODO: refactor commonize saveByDrawioModal and saveByHandsontableModal
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null) {
-      return;
-    }
-
-    // disable if share link
-    if (shareLinkId != null) {
-      return;
-    }
-
-    const currentMarkdown = currentPage.revision.body;
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
-    };
-
-    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
-
-    try {
-      const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
-
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error);
-    }
-  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
-
-  // set handler to open DrawioModal
-  useEffect(() => {
-    // disable if share link
-    if (shareLinkId != null) {
-      return;
-    }
-
-    const handler = (data: DrawioEditByViewerProps) => {
-      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
-    };
-    globalEmitter.on('launchDrawioModal', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('launchDrawioModal', handler);
-    };
-  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
-
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
-      return;
-    }
-
-    const currentMarkdown = currentPage.revision.body;
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
-    };
-
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
-
-    try {
-      const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
-
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error);
-    }
-  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
-
-  // set handler to open HandsonTableModal
-  useEffect(() => {
-    if (currentPage == null || shareLinkId != null) {
-      return;
-    }
-
-    const handler = (bol: number, eol: number) => {
-      const markdown = currentPage.revision.body;
-      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
-    };
-    globalEmitter.on('launchHandsonTableModal', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('launchHandsonTableModal', handler);
-    };
-  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
-
-  if (currentPage == null || isGuestUser == null || rendererOptions == null) {
-    const entries = Object.entries({
-      currentPage, isGuestUser, rendererOptions,
-    })
-      .map(([key, value]) => [key, value == null ? 'null' : undefined])
-      .filter(([, value]) => value != null);
-
-    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
-    return null;
-  }
-
-  const { _id: revisionId, body: markdown } = currentPage.revision;
-
-  return (
-    <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
-
-      { revisionId != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-      )}
-
-      { !isGuestUser && (
-        <>
-          <GridEditModal />
-          <LinkEditModal />
-        </>
-      )}
-    </div>
-  );
-
-};

+ 30 - 132
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,157 +1,55 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React from 'react';
 
-import { pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import { SocketEventName } from '~/interfaces/websocket';
-import {
-  useIsEditable, useShareLinkId, useIsNotFound,
-} from '~/stores/context';
-import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
-import {
-  useSetRemoteLatestPageData,
-} from '~/stores/remote-latest-page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import { useGlobalSocket } from '~/stores/websocket';
 
-import CustomTabContent from '../CustomNavigation/CustomTabContent';
-import { Page } from '../Page';
-import { UserInfoProps } from '../User/UserInfo';
+import { useHackmdDraftUpdatedEffect } from '~/client/services/side-effects/hackmd-draft-updated';
+import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
+import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
+import { useIsEditable } from '~/stores/context';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-const { isUsersHomePage } = pagePathUtils;
+import { LazyRenderer } from '../Common/LazyRenderer';
 
 
 const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
-const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
-
-
-const PageView = React.memo((): JSX.Element => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: shareLinkId } = useShareLinkId();
-  const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
-
-  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
-
-  const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
-
-  const { data: socket } = useGlobalSocket();
-
-  const setLatestRemotePageData = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-
-    const remoteData = {
-      remoteRevisionId: s2cMessagePageUpdated.revisionId,
-      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
-      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
-      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
-      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
-      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
-    };
-    setRemoteLatestPageData(remoteData);
-  }, [setRemoteLatestPageData]);
-
-  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-    if (s2cMessagePageUpdated.pageId === currentPage?._id) {
-      mutateIsHackmdDraftUpdatingInRealtime(true);
-    }
-  }, [currentPage?._id, mutateIsHackmdDraftUpdatingInRealtime]);
-
-  // listen socket for someone updating this page
-  useEffect(() => {
-
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
-
-    return () => {
-      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
-    };
-
-  }, [setLatestRemotePageData, socket]);
 
-  // listen socket for hackmd saved
-  useEffect(() => {
 
-    if (socket == null) { return }
+type Props = {
+  pageView: JSX.Element,
+}
 
-    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
-
-    return () => {
-      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
-    };
-  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
-
-  return (
-    <>
-      { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
-      { !isNotFound && <Page currentPage={currentPage ?? undefined} /> }
-      { isNotFound && <NotFoundPage /> }
-    </>
-  );
-});
-PageView.displayName = 'PageView';
-
-
-const DisplaySwitcher = React.memo((): JSX.Element => {
+export const DisplaySwitcher = (props: Props): JSX.Element => {
+  const { pageView } = props;
 
+  const { data: editorMode = EditorMode.View } = useEditorMode();
   const { data: isEditable } = useIsEditable();
 
-  const { data: editorMode = EditorMode.View } = useEditorMode();
+  usePageUpdatedEffect();
+  useHashChangedEffect();
+  useHackmdDraftUpdatedEffect();
 
   const isViewMode = editorMode === EditorMode.View;
 
-  const navTabMapping = useMemo(() => {
-    return {
-      [EditorMode.View]: {
-        Content: () => (
-          <div data-testid="page-view" id="page-view">
-            <PageView />
-          </div>
-        ),
-      },
-      [EditorMode.Editor]: {
-        Content: () => (
-          isEditable
-            ? (
-              <div data-testid="page-editor" id="page-editor">
-                <PageEditor />
-              </div>
-            )
-            : <></>
-        ),
-      },
-      [EditorMode.HackMD]: {
-        Content: () => (
-          isEditable
-            ? (
-              <div id="page-editor-with-hackmd">
-                <PageEditorByHackmd />
-              </div>
-            )
-            : <></>
-        ),
-      },
-    };
-  }, [isEditable]);
-
-
   return (
     <>
-      <CustomTabContent activeTab={editorMode} navTabMapping={navTabMapping} />
+      { isViewMode && pageView }
+
+      <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
+        <div data-testid="page-editor" id="page-editor" className="editor-root">
+          <PageEditor />
+        </div>
+      </LazyRenderer>
+
+      <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.HackMD}>
+        <div id="page-editor-with-hackmd" className="editor-root">
+          <PageEditorByHackmd />
+        </div>
+      </LazyRenderer>
 
       { isEditable && !isViewMode && <EditorNavbarBottom /> }
-      { isEditable && <HashChanged></HashChanged> }
     </>
   );
-});
-DisplaySwitcher.displayName = 'DisplaySwitcher';
-
-export default DisplaySwitcher;
+};

+ 102 - 0
packages/app/src/components/Page/PageContents.tsx

@@ -0,0 +1,102 @@
+import React, { useEffect } from 'react';
+
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import type { HtmlElementNode } from 'rehype-toc';
+
+import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
+import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useCurrentPathname } from '~/stores/context';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useViewOptions } from '~/stores/renderer';
+import { useCurrentPageTocNode } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
+import loggerFactory from '~/utils/logger';
+
+import RevisionRenderer from './RevisionRenderer';
+
+
+const logger = loggerFactory('growi:Page');
+
+
+export const PageContents = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentPathname } = useCurrentPathname();
+  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
+
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions((toc: HtmlElementNode) => {
+    mutateCurrentPageTocNode(toc);
+  });
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+  useHandsontableModalLauncherForView({
+    onSaveSuccess: (newMarkdown) => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      if (!isSharedPage) {
+        mutateCurrentPage();
+      }
+      mutateEditingMarkdown(newMarkdown);
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+  useDrawioModalLauncherForView({
+    onSaveSuccess: (newMarkdown) => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      if (!isSharedPage) {
+        mutateCurrentPage();
+      }
+      mutateEditingMarkdown(newMarkdown);
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+
+  if (currentPage == null || rendererOptions == null) {
+    const entries = Object.entries({
+      currentPage, rendererOptions,
+    })
+      .map(([key, value]) => [key, value == null ? 'null' : undefined])
+      .filter(([, value]) => value != null);
+
+    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
+
+    return <></>;
+  }
+
+  const { _id: revisionId, body: markdown } = currentPage.revision;
+
+  return (
+    <>
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+    </>
+  );
+
+};

+ 0 - 0
packages/app/src/components/Page.module.scss → packages/app/src/components/Page/PageView.module.scss


+ 129 - 0
packages/app/src/components/Page/PageView.tsx

@@ -0,0 +1,129 @@
+import React, { useMemo } from 'react';
+
+import { type IPagePopulatedToShowRevision, pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
+
+
+import {
+  useIsForbidden, useIsIdenticalPath, useIsNotCreatable, useIsNotFound,
+} from '~/stores/context';
+import { useIsMobile } from '~/stores/ui';
+
+import type { CommentsProps } from '../Comments';
+import { MainPane } from '../Layout/MainPane';
+import { PageAlerts } from '../PageAlert/PageAlerts';
+import { PageContentFooter } from '../PageContentFooter';
+import type { PageSideContentsProps } from '../PageSideContents';
+import { UserInfo } from '../User/UserInfo';
+import type { UsersHomePageFooterProps } from '../UsersHomePageFooter';
+
+import { PageContents } from './PageContents';
+
+import styles from './PageView.module.scss';
+
+
+const { isUsersHomePage } = pagePathUtils;
+
+
+const NotCreatablePage = dynamic(() => import('../NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
+const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
+const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../UsersHomePageFooter')
+  .then(mod => mod.UsersHomePageFooter), { ssr: false });
+
+const IdenticalPathPage = (): JSX.Element => {
+  const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
+  return <IdenticalPathPage />;
+};
+
+
+type Props = {
+  pagePath: string,
+  page?: IPagePopulatedToShowRevision,
+  ssrBody?: JSX.Element,
+}
+
+export const PageView = (props: Props): JSX.Element => {
+  const {
+    pagePath, page, ssrBody,
+  } = props;
+
+  const pageId = page?._id;
+
+  const { data: isIdenticalPathPage } = useIsIdenticalPath();
+  const { data: isForbidden } = useIsForbidden();
+  const { data: isNotCreatable } = useIsNotCreatable();
+  const { data: isNotFound } = useIsNotFound();
+  const { data: isMobile } = useIsMobile();
+
+  const specialContents = useMemo(() => {
+    if (isIdenticalPathPage) {
+      return <IdenticalPathPage />;
+    }
+    if (isForbidden) {
+      return <ForbiddenPage />;
+    }
+    if (isNotCreatable) {
+      return <NotCreatablePage />;
+    }
+    if (isNotFound) {
+      return <NotFoundPage />;
+    }
+  }, [isForbidden, isIdenticalPathPage, isNotCreatable, isNotFound]);
+
+  const sideContents = !isNotFound && !isNotCreatable
+    ? (
+      <PageSideContents page={page} />
+    )
+    : <></>;
+
+  const footerContents = !isIdenticalPathPage && !isNotFound && page != null
+    ? (
+      <>
+        { pageId != null && pagePath != null && (
+          <Comments pageId={pageId} pagePath={pagePath} revision={page.revision} />
+        ) }
+        { pagePath != null && isUsersHomePage(pagePath) && (
+          <UsersHomePageFooter creatorId={page.creator._id}/>
+        ) }
+        <PageContentFooter page={page} />
+      </>
+    )
+    : <></>;
+
+  const isUsersHomePagePath = isUsersHomePage(pagePath);
+
+  const contents = specialContents != null
+    ? <></>
+    // TODO: show SSR body
+    // : (() => {
+    //   const PageContents = dynamic(() => import('./PageContents').then(mod => mod.PageContents), {
+    //     ssr: false,
+    //     // loading: () => ssrBody ?? <></>,
+    //   });
+    //   return <PageContents />;
+    // })();
+    : <PageContents />;
+
+  return (
+    <MainPane
+      sideContents={sideContents}
+      footerContents={footerContents}
+    >
+      <PageAlerts />
+
+      { specialContents }
+      { specialContents == null && (
+        <>
+          { isUsersHomePagePath && <UserInfo author={page?.creator} /> }
+          <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+            { contents }
+          </div>
+        </>
+      ) }
+
+    </MainPane>
+  );
+};

+ 1 - 1
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { RendererOptions } from '~/services/renderer/renderer';
+import type { RendererOptions } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 
 

+ 0 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -19,7 +19,6 @@ import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
-import { DrawioModal } from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';

+ 64 - 0
packages/app/src/components/ShareLink/ShareLinkPageContents.tsx

@@ -0,0 +1,64 @@
+import React, { useEffect } from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import type { HtmlElementNode } from 'rehype-toc';
+
+import { useViewOptions } from '~/stores/renderer';
+import { useCurrentPageTocNode } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
+import loggerFactory from '~/utils/logger';
+
+import RevisionRenderer from '../Page/RevisionRenderer';
+
+
+const logger = loggerFactory('growi:Page');
+
+
+export type ShareLinkPageContentsProps = {
+  page?: IPagePopulatedToShowRevision,
+}
+
+export const ShareLinkPageContents = (props: ShareLinkPageContentsProps): JSX.Element => {
+  const { page } = props;
+
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions((toc: HtmlElementNode) => {
+    mutateCurrentPageTocNode(toc);
+  });
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+
+  if (page == null || rendererOptions == null) {
+    const entries = Object.entries({
+      page, rendererOptions,
+    })
+      .map(([key, value]) => [key, value == null ? 'null' : undefined])
+      .filter(([, value]) => value != null);
+
+    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
+
+    return <></>;
+  }
+
+  const { _id: revisionId, body: markdown } = page.revision;
+
+  return (
+    <>
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+    </>
+  );
+
+};

+ 0 - 2
packages/app/src/components/TableOfContents.tsx

@@ -21,8 +21,6 @@ const TableOfContents = (): JSX.Element => {
 
   const isUserPage = currentPagePath != null && _isUserPage(currentPagePath);
 
-  // const [tocHtml, setTocHtml] = useState('');
-
   const { data: rendererOptions } = useTocOptions();
 
   const calcViewHeight = useCallback(() => {

+ 30 - 67
packages/app/src/pages/[[...path]].page.tsx

@@ -20,25 +20,19 @@ import { useRouter } from 'next/router';
 import superjson from 'superjson';
 
 import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
-import { Comments } from '~/components/Comments';
-import { MainPane } from '~/components/Layout/MainPane';
-import { PageAlerts } from '~/components/PageAlert/PageAlerts';
-// import { useTranslation } from '~/i18n';
-import { PageContentFooter } from '~/components/PageContentFooter';
+import { PageView } from '~/components/Page/PageView';
+import RevisionRenderer from '~/components/Page/RevisionRenderer';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-// import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
-// import { useRendererSettings } from '~/stores/renderer';
-// import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import type { EditorConfig } from '~/interfaces/editor-settings';
-import { IPageGrantData } from '~/interfaces/page';
+import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
@@ -51,17 +45,11 @@ import {
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
-// import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
-
-// import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
 import type { GrowiSubNavigationSwitcherProps } from '../components/Navbar/GrowiSubNavigationSwitcher';
-import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-// import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
-// import PageStatusAlert from '../client/js/components/PageStatusAlert';
-import type { PageSideContentsProps } from '../components/PageSideContents';
+import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import {
   useCurrentUser,
   useIsLatestRevision,
@@ -87,14 +75,9 @@ declare global {
 }
 
 
-const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
-const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
-const PageSideContents = dynamic<PageSideContentsProps>(() => import('../components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() => import('../components/Navbar/GrowiSubNavigationSwitcher')
   .then(mod => mod.GrowiSubNavigationSwitcher), { ssr: false });
-const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
-  .then(mod => mod.UsersHomePageFooter), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
@@ -102,7 +85,7 @@ const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').th
 const logger = loggerFactory('growi:pages:all');
 
 const {
-  isPermalink: _isPermalink, isUsersHomePage, isTrashPage: _isTrashPage, isCreatablePage, isTopPage,
+  isPermalink: _isPermalink, isTrashPage: _isTrashPage, isCreatablePage,
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
@@ -148,11 +131,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
 };
 
-const IdenticalPathPage = (): JSX.Element => {
-  const IdenticalPathPage = dynamic(() => import('../components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
-  return <IdenticalPathPage />;
-};
-
 const PutbackPageModal = (): JSX.Element => {
   const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
   return <PutbackPageModal />;
@@ -274,7 +252,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { pageWithMeta, userUISettings } = props;
 
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
+  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
+  const revisionBody = pageWithMeta?.data.revision?.body;
 
   useCurrentPageId(pageId ?? null);
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
@@ -285,7 +264,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
-  useEditingMarkdown(pageWithMeta?.data.revision?.body);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
@@ -314,30 +293,16 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     }
   }, [props.currentPathname, router]);
 
-  const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
-
-  const title = generateCustomTitleForPage(props, pagePath ?? '');
-
+  // initialize mutateEditingMarkdown only once per page
+  useEffect(() => {
+    mutateEditingMarkdown(revisionBody);
+  }, [mutateEditingMarkdown, revisionBody]);
 
-  const sideContents = !props.isNotFound && !props.isNotCreatable
-    ? (
-      <PageSideContents page={pageWithMeta?.data} />
-    )
-    : <></>;
+  const title = generateCustomTitleForPage(props, pagePath);
 
-  const footerContents = !props.isIdenticalPathPage && !props.isNotFound && pageWithMeta != null
-    ? (
-      <>
-        { pagePath != null && !isTopPagePath && (
-          <Comments pageId={pageId} pagePath={pagePath} revision={pageWithMeta.data.revision} />
-        ) }
-        { isUsersHomePage(pageWithMeta.data.path) && (
-          <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
-        ) }
-        <PageContentFooter page={pageWithMeta.data} />
-      </>
-    )
-    : <></>;
+  // TODO: show SSR body
+  // const rendererOptions = generateSSRViewOptions(props.rendererConfig, pagePath);
+  // const ssrBody = <RevisionRenderer rendererOptions={rendererOptions} markdown={revisionBody ?? ''} />;
 
   return (
     <>
@@ -350,6 +315,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
             <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           </div>
         </header>
+
         <div className="d-edit-none">
           <GrowiSubNavigationSwitcher isLinkSharingDisabled={props.disableLinkSharing} />
         </div>
@@ -357,21 +323,18 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
         <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
-        <MainPane
-          sideContents={sideContents}
-          footerContents={footerContents}
-        >
-          <PageAlerts />
-          { props.isIdenticalPathPage && <IdenticalPathPage />}
-          { !props.isIdenticalPathPage && (
-            <>
-              { props.isForbidden && <ForbiddenPage /> }
-              { props.isNotCreatable && <NotCreatablePage />}
-              { !props.isForbidden && !props.isNotCreatable && <DisplaySwitcher />}
-            </>
-          ) }
-          <PageStatusAlert />
-        </MainPane>
+        <DisplaySwitcher
+          pageView={
+            <PageView
+              pagePath={pagePath}
+              page={pageWithMeta?.data}
+              // TODO: show SSR body
+              // ssrBody={ssrBody}
+            />
+          }
+        />
+
+        <PageStatusAlert />
 
         {shouldRenderPutbackPageModal && <PutbackPageModal />}
       </div>

+ 0 - 6
packages/app/src/pages/_document.page.tsx

@@ -100,12 +100,6 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
       <Html>
         <Head>
           {this.renderCustomScript(customScript)}
-          <link rel='preload' href="/static/fonts/PressStart2P-latin.woff2" as="font" type="font/woff2" />
-          <link rel='preload' href="/static/fonts/PressStart2P-latin-ext.woff2" as="font" type="font/woff2" />
-          <link rel='preload' href="/static/fonts/Lato-Regular-latin.woff2" as="font" type="font/woff2" />
-          <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
-          <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
-          <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel="stylesheet" key="link-theme" href={themeHref} />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}

+ 26 - 4
packages/app/src/pages/share/[[...path]].page.tsx

@@ -13,14 +13,17 @@ import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { MainPane } from '~/components/Layout/MainPane';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
-import { Page } from '~/components/Page';
+import RevisionRenderer from '~/components/Page/RevisionRenderer';
+import ShareLinkAlert from '~/components/Page/ShareLinkAlert';
 import type { PageSideContentsProps } from '~/components/PageSideContents';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import type { ShareLinkPageContentsProps } from '~/components/ShareLink/ShareLinkPageContents';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
   useCurrentUser, useCurrentPageId, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
@@ -36,7 +39,6 @@ const logger = loggerFactory('growi:next-page:share');
 
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('~/components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 // const Comments = dynamic(() => import('~/components/Comments').then(mod => mod.Comments), { ssr: false });
-const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
 
 type Props = CommonProps & {
@@ -105,8 +107,14 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
   const shareLink = props.shareLink;
 
-  const title = generateCustomTitleForPage(props, props.shareLinkRelatedPage?.path ?? '');
+  const pagePath = props.shareLinkRelatedPage?.path ?? '';
+  const revisionBody = props.shareLinkRelatedPage?.revision.body;
 
+  const title = generateCustomTitleForPage(props, pagePath);
+
+  // TODO: show SSR body
+  // const rendererOptions = generateSSRViewOptions(props.rendererConfig, pagePath);
+  // const ssrBody = <RevisionRenderer rendererOptions={rendererOptions} markdown={revisionBody ?? ''} />;
 
   const sideContents = shareLink != null
     ? <PageSideContents page={shareLink.relatedPage} />
@@ -120,6 +128,18 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   //   )
   //   : <></>;
 
+  const contents = (() => {
+    const ShareLinkPageContents = dynamic<ShareLinkPageContentsProps>(
+      () => import('~/components/ShareLink/ShareLinkPageContents').then(mod => mod.ShareLinkPageContents),
+      {
+        ssr: false,
+        // TODO: show SSR body
+        // loading: () => ssrBody,
+      },
+    );
+    return <ShareLinkPageContents page={props.shareLinkRelatedPage} />;
+  })();
+
   return (
     <>
       <Head>
@@ -166,7 +186,9 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
           {(isShowSharedPage && shareLink != null) && (
             <>
               <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-              <Page currentPage={props.shareLinkRelatedPage} />
+              <div className="mb-5">
+                { contents }
+              </div>
             </>
           )}
         </MainPane>

+ 15 - 2
packages/app/src/pages/utils/commons.ts

@@ -45,8 +45,21 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     currentUser = user.toObject();
   }
 
-  // eslint-disable-next-line max-len, no-nested-ternary
-  const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
+  // Redirect destination for page transition by next/link
+  let redirectDestination: string | null = null;
+  if (!crowi.aclService.isGuestAllowedToRead() && currentUser == null) {
+    redirectDestination = '/login';
+  }
+  else if (!isMaintenanceMode && currentPathname === '/maintenance') {
+    redirectDestination = '/';
+  }
+  else if (isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance')) {
+    redirectDestination = '/maintenance';
+  }
+  else {
+    redirectDestination = null;
+  }
+
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;

+ 1 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -599,7 +599,7 @@ module.exports = (crowi) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+    const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
 
     const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 

+ 24 - 0
packages/app/src/server/service/page.ts

@@ -4035,6 +4035,30 @@ class PageService {
     return pages;
   }
 
+  /**
+   * Find all pages in trash page
+   */
+  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<PageDocument[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // https://regex101.com/r/KYZWls/1
+    // ex. /trash/.*
+    const regexp = new RegExp('^/trash\\/.*$');
+    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+
+    await queryBuilder.addViewerCondition(user, userGroups);
+
+    const pages = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .lean()
+      .exec();
+
+    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    return pages;
+  }
+
   async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 

+ 1 - 0
packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts

@@ -1,3 +1,4 @@
+// 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' are included in 'remark-wiki-link' package
 import { fromMarkdown, toMarkdown } from 'mdast-util-wiki-link';
 import { syntax } from 'micromark-extension-wiki-link';
 import { Plugin } from 'unified';

+ 60 - 2
packages/app/src/services/renderer/renderer.tsx

@@ -77,9 +77,14 @@ const commonSanitizeOption: SanitizeOption = deepmerge(
   },
 );
 
+let isInjectedCustomSanitaizeOption = false;
+
 const injectCustomSanitizeOption = (config: RendererConfig) => {
-  commonSanitizeOption.tagNames = config.tagWhiteList;
-  commonSanitizeOption.attributes = deepmerge(commonSanitizeAttributes, config.attrWhiteList ?? {});
+  if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    commonSanitizeOption.tagNames = config.tagWhiteList;
+    commonSanitizeOption.attributes = deepmerge(commonSanitizeAttributes, config.attrWhiteList ?? {});
+    isInjectedCustomSanitaizeOption = true;
+  }
 };
 
 const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
@@ -220,6 +225,7 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   if (config.isEnabledXssPrevention) {
     verifySanitizePlugin(options);
   }
+
   return options;
 };
 
@@ -283,6 +289,58 @@ export const generateSimpleViewOptions = (
   return options;
 };
 
+export const generateSSRViewOptions = (
+    config: RendererConfig,
+    pagePath: string,
+): RendererOptions => {
+  const options = generateCommonOptions(pagePath);
+
+  const { remarkPlugins, rehypePlugins, components } = options;
+
+  // add remark plugins
+  remarkPlugins.push(
+    math,
+    xsvToTable.remarkPlugin,
+    lsxGrowiPlugin.remarkPlugin,
+    table.remarkPlugin,
+  );
+
+  const isEnabledLinebreaks = config.isEnabledLinebreaks;
+
+  if (isEnabledLinebreaks) {
+    remarkPlugins.push(breaks);
+  }
+
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(
+      commonSanitizeOption,
+      lsxGrowiPlugin.sanitizeOption,
+    )]
+    : () => {};
+
+  // add rehype plugins
+  rehypePlugins.push(
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    rehypeSanitizePlugin,
+    katex,
+  );
+
+  // add components
+  if (components != null) {
+    components.lsx = LsxImmutable;
+    components.table = Table;
+  }
+
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
+  return options;
+};
+
 export const generatePreviewOptions = (config: RendererConfig, pagePath: string): RendererOptions => {
   const options = generateCommonOptions(pagePath);
 

+ 6 - 2
packages/app/src/stores/editor.tsx

@@ -10,15 +10,19 @@ import { IEditorSettings } from '~/interfaces/editor-settings';
 import { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 import {
+  useCurrentPathname,
   useCurrentUser, useDefaultIndentSize, useIsGuestUser,
 } from './context';
 // import { localStorageMiddleware } from './middlewares/sync-to-storage';
-import { useCurrentPagePath, useSWRxTagsInfo } from './page';
+import { useSWRxTagsInfo } from './page';
 import { useStaticSWR } from './use-static-swr';
 
 
 export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  const { data: currentPagePath } = useCurrentPagePath();
+  // need to include useCurrentPathname not useCurrentPagePath
+  // https://github.com/weseek/growi/pull/7301
+  const { data: currentPagePath } = useCurrentPathname();
+
   return useStaticSWR(['editingMarkdown', currentPagePath], initialData);
 };
 

+ 6 - 2
packages/app/src/stores/personal-settings.tsx

@@ -1,9 +1,9 @@
 import { useTranslation } from 'next-i18next';
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
-
 import { IExternalAccount } from '~/interfaces/external-account';
 import { IUser } from '~/interfaces/user';
+import { useIsGuestUser } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
 import { apiv3Get, apiv3Put } from '../client/util/apiv3-client';
@@ -14,8 +14,12 @@ const logger = loggerFactory('growi:stores:personal-settings');
 
 
 export const useSWRxPersonalSettings = (config?: SWRConfiguration): SWRResponse<IUser, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const key = !isGuestUser ? '/personal-setting' : null;
+
   return useSWR(
-    '/personal-setting',
+    key,
     endpoint => apiv3Get(endpoint).then(response => response.data.currentUser),
     config,
   );

+ 4 - 1
packages/app/src/stores/renderer.tsx

@@ -45,7 +45,7 @@ export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
   const { data: rendererConfig } = useRendererConfig();
   const { data: tocNode } = useCurrentPageTocNode();
 
-  const isAllDataValid = rendererConfig != null;
+  const isAllDataValid = currentPagePath != null && rendererConfig != null && tocNode != null;
 
   const key = isAllDataValid
     ? ['tocOptions', currentPagePath, tocNode, rendererConfig]
@@ -54,6 +54,9 @@ export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
   return useSWRImmutable<RendererOptions, Error>(
     key,
     (rendererId, path, tocNode, rendererConfig) => generateTocOptions(rendererConfig, tocNode),
+    {
+      fallbackData: isAllDataValid ? generateTocOptions(rendererConfig, tocNode) : undefined,
+    },
   );
 };
 

+ 28 - 50
packages/app/src/styles/_mixins.scss

@@ -24,63 +24,41 @@
     + 25px //   add .btn-open-dropzone height
     + 30px; //  add .navbar-editor height
 
-  .main {
+  .editor-root {
     width: 100%;
-    height: calc(100vh - #{$editor-margin-top});
+    height: calc(100vh - #{$header-plus-footer});
+    min-height: calc(100vh - #{$header-plus-footer}); // for IE11
     margin-top: 0px !important;
 
-    .grw-container-convertible {
-      max-width: unset;
-      padding: 0;
-      margin: 0;
-    }
+    // left(editor)
+    .page-editor-editor-container {
+      height: calc(100vh - #{$header-plus-footer});
+      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
 
-    &,
-    .content-main,
-    .tab-content {
-      display: flex;
-      flex: 1;
-      flex-direction: column;
-
-      .tab-pane {
-        height: calc(100vh - #{$header-plus-footer});
-        min-height: calc(100vh - #{$header-plus-footer}); // for IE11
+      .react-codemirror2,
+      .CodeMirror,
+      .CodeMirror-scroll,
+      .textarea-editor {
+        height: calc(100vh - #{$editor-margin});
       }
+    }
 
-      #page-editor {
-        // right(preview)
-        &,
-        & > .row,
-        .page-editor-preview-container,
-        .page-editor-preview-body {
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-        }
-
-        // left(editor)
-        .page-editor-editor-container {
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-
-          .react-codemirror2,
-          .CodeMirror,
-          .CodeMirror-scroll,
-          .textarea-editor {
-            height: calc(100vh - #{$editor-margin});
-          }
-        }
-      }
+    // right(preview)
+    .page-editor-preview-container,
+    .page-editor-preview-body {
+      height: calc(100vh - #{$header-plus-footer});
+      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
+    }
+  }
 
-      #page-editor-with-hackmd {
-        &,
-        .hackmd-preinit,
-        .hackmd-error,
-        #iframe-hackmd-container > iframe {
-          width: 100%;
-          height: calc(100vh - #{$header-plus-footer});
-          min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-        }
-      }
+  .editor-root#page-editor-with-hackmd {
+    &,
+    .hackmd-preinit,
+    .hackmd-error,
+    #iframe-hackmd-container > iframe {
+      width: 100%;
+      height: calc(100vh - #{$header-plus-footer});
+      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
     }
   }
 }

+ 6 - 2
packages/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts

@@ -112,7 +112,9 @@ context('Click page icons button', () => {
       // do
       cy.get('#po-total-bookmarks').click({force: true});
       // wait until
-      return cy.get('.user-list-popover').then($elem => $elem.is(':visible'));
+      return cy.get('body').within(() => {
+        return Cypress.$('.user-list-popover').is(':visible');
+      });
     });
     cy.waitUntilSpinnerDisappear();
     cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}8-bookmarks-counter`) });
@@ -137,7 +139,9 @@ context('Click page icons button', () => {
       // do
       cy.get('#po-total-bookmarks').click({force: true});
       // wait until
-      return cy.get('.user-list-popover').then($elem => $elem.is(':visible'));
+      return cy.get('body').within(() => {
+        return Cypress.$('.user-list-popover').is(':visible');
+      });
     });
     cy.waitUntilSpinnerDisappear();
     cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}10-bookmarks-counter`) });

+ 10 - 3
packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -45,8 +45,13 @@ context('Modal for page operation', () => {
       cy.getByTestid('btn-create-memo').click();
     });
     cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
-    cy.get('.layout-root').should('not.have.class', 'editing');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('save-page-btn').should('be.visible').click();
+      // wait until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    });
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
 
@@ -64,7 +69,9 @@ context('Modal for page operation', () => {
       // do
       cy.getByTestid('newPageBtn').click({force: true});
       // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+      return cy.get('body').within(() => {
+        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
+      });
     });
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "main": "dist/index.js",

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "license": "MIT",
   "main": "dist/libs/index.js",
   "files": [

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 4 - 4
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -20,9 +20,9 @@
     "test": ""
   },
   "dependencies": {
-    "@growi/core": "^6.0.4",
-    "@growi/remark-growi-directive": "^6.0.4",
-    "@growi/ui": "^6.0.4",
+    "@growi/core": "^6.0.5-RC.0",
+    "@growi/remark-growi-directive": "^6.0.5-RC.0",
+    "@growi/ui": "^6.0.5-RC.0",
     "swr": "^1.3.0"
   },
   "devDependencies": {

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.0.4",
+  "version": "6.0.5-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.0.4",
+    "@growi/slack": "^6.0.5-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.0.4",
+  "version": "6.0.5-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": ["growi"],
@@ -17,7 +17,7 @@
     "test": "jest --verbose"
   },
   "dependencies": {
-    "@growi/core": "^6.0.4"
+    "@growi/core": "^6.0.5-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",