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

Merge branch 'support/apply-nextjs-2' into feat/create-invited-page

jam411 3 лет назад
Родитель
Сommit
fcc186ad81
43 измененных файлов с 538 добавлено и 584 удалено
  1. 0 88
      packages/app/src/client/services/EditorContainer.js
  2. 6 12
      packages/app/src/client/services/page-operation.ts
  3. 3 1
      packages/app/src/components/Admin/UserManagement.jsx
  4. 5 0
      packages/app/src/components/Admin/UserManagement.module.scss
  5. 0 3
      packages/app/src/components/CommonStyles/katex.module.scss
  6. 15 21
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  7. 92 0
      packages/app/src/components/Layout/SearchResultLayout.module.scss
  8. 34 0
      packages/app/src/components/Layout/SearchResultLayout.tsx
  9. 7 6
      packages/app/src/components/MyDraftList/MyDraftList.jsx
  10. 13 8
      packages/app/src/components/Page.tsx
  11. 0 79
      packages/app/src/components/Page/RevisionBody.jsx
  12. 1 5
      packages/app/src/components/Page/RevisionRenderer.tsx
  13. 98 166
      packages/app/src/components/PageEditor.tsx
  14. 1 0
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  15. 10 21
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  16. 1 1
      packages/app/src/components/PageEditor/GridEditModal.jsx
  17. 1 1
      packages/app/src/components/PageEditor/GridEditModal.module.scss
  18. 4 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  19. 0 0
      packages/app/src/components/PageEditor/LinkEditPreview.module.scss
  20. 0 25
      packages/app/src/components/PageEditor/OptionsSelector.tsx
  21. 0 1
      packages/app/src/components/PageEditor/Preview.tsx
  22. 1 1
      packages/app/src/components/PageEditor/ScrollSyncHelper.js
  23. 1 1
      packages/app/src/components/PageHistory/Revision.jsx
  24. 2 2
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  25. 2 2
      packages/app/src/components/SavePageControls.tsx
  26. 36 0
      packages/app/src/components/SearchForm.module.scss
  27. 3 1
      packages/app/src/components/SearchForm.tsx
  28. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  29. 13 0
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  30. 62 54
      packages/app/src/components/UncontrolledCodeMirror.tsx
  31. 0 1
      packages/app/src/interfaces/editor-settings.ts
  32. 0 4
      packages/app/src/pages/[[...path]].page.tsx
  33. 7 13
      packages/app/src/pages/_private-legacy-pages.page.tsx
  34. 8 12
      packages/app/src/pages/_search.page.tsx
  35. 52 17
      packages/app/src/pages/me/[[...path]].page.tsx
  36. 0 1
      packages/app/src/server/models/editor-settings.ts
  37. 2 3
      packages/app/src/server/routes/apiv3/personal-setting.js
  38. 5 5
      packages/app/src/server/routes/index.js
  39. 13 4
      packages/app/src/services/renderer/rehype-plugins/add-class.ts
  40. 19 5
      packages/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  41. 10 12
      packages/app/src/services/renderer/renderer.tsx
  42. 4 0
      packages/app/src/stores/context.tsx
  43. 6 5
      packages/app/src/styles/style-next.scss

+ 0 - 88
packages/app/src/client/services/EditorContainer.js

@@ -1,88 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:services:EditorContainer');
-
-
-/*
-* TODO: Omit Unstated: EditorContainer
-* => https://redmine.weseek.co.jp/issues/103246
-*/
-
-
-/**
- * Service container related to options for Editor/Preview
- * @extends {Container} unstated Container
- */
-export default class EditorContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    this.state = {
-      tags: null,
-    };
-
-    this.initDrafts();
-
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'EditorContainer';
-  }
-
-  /**
-   * initialize state for drafts
-   */
-  initDrafts() {
-    this.drafts = {};
-
-    // restore data from localStorage
-    const contents = window.localStorage.drafts;
-    if (contents != null) {
-      try {
-        this.drafts = JSON.parse(contents);
-      }
-      catch (e) {
-        window.localStorage.removeItem('drafts');
-      }
-    }
-
-    if (this.state.pageId == null) {
-      const draft = this.findDraft(this.state.path);
-      if (draft != null) {
-        this.state.markdown = draft;
-      }
-    }
-  }
-
-  clearDraft(path) {
-    delete this.drafts[path];
-    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
-  }
-
-  clearAllDrafts() {
-    window.localStorage.removeItem('drafts');
-  }
-
-  saveDraft(path, body) {
-    this.drafts[path] = body;
-    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
-  }
-
-  findDraft(path) {
-    if (this.drafts != null && this.drafts[path]) {
-      return this.drafts[path];
-    }
-
-    return null;
-  }
-
-}

+ 6 - 12
packages/app/src/client/services/page-operation.ts

@@ -97,8 +97,8 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
-
-export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
+// TODO: define return type
+const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
   // clone
   const params = Object.assign(tmpParams, {
     path: pagePath,
@@ -111,7 +111,8 @@ export const createPage = async(pagePath: string, markdown: string, tmpParams: O
   return { page, tags, revision };
 };
 
-export const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
+// TODO: define return type
+const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
   // clone
   const params = Object.assign(tmpParams, {
     page_id: pageId,
@@ -132,8 +133,8 @@ type PageInfo= {
   revisionId: Nullable<string>,
 }
 
-
-export const saveAndReload = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
+// TODO: define return type
+export const saveOrUpdate = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
   const { path, pageId, revisionId } = pageInfo;
 
   const options = Object.assign({}, optionsToSave);
@@ -168,12 +169,5 @@ export const saveAndReload = async(optionsToSave: OptionsToSave, pageInfo: PageI
     res = await updatePage(pageId, revisionId, markdown, options);
   }
 
-  /*
-  * TODO: implement Draft function => https://redmine.weseek.co.jp/issues/103246
-  */
-  // const editorContainer = this.appContainer.getContainer('EditorContainer');
-  // editorContainer.clearDraft(path);
-  window.location.href = path;
-
   return res;
 };

+ 3 - 1
packages/app/src/components/Admin/UserManagement.jsx

@@ -14,6 +14,8 @@ import InviteUserControl from './Users/InviteUserControl';
 import PasswordResetModal from './Users/PasswordResetModal';
 import UserTable from './Users/UserTable';
 
+import styles from './UserManagement.module.scss';
+
 class UserManagement extends React.Component {
 
   constructor(props) {
@@ -129,7 +131,7 @@ class UserManagement extends React.Component {
       adminUsersContainer.state.searchText.length > 0
         ? (
           <i
-            className="icon-close search-clear"
+            className={`icon-close ${styles['search-clear']}`}
             onClick={() => {
               adminUsersContainer.clearSearchText();
               this.searchUserElement.value = '';

+ 5 - 0
packages/app/src/components/Admin/UserManagement.module.scss

@@ -0,0 +1,5 @@
+// styles for admin user search
+.search-clear :global {
+  top: 90px;
+  right: 4px;
+}

+ 0 - 3
packages/app/src/components/CommonStyles/katex.module.scss

@@ -1,3 +0,0 @@
-.katex-container :global {
-  @import '~katex/dist/katex.min';
-}

+ 15 - 21
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -3,31 +3,30 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import InAppNotificationList from './InAppNotificationList';
+
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useShowPageLimitationXL } from '~/stores/context';
+import loggerFactory from '~/utils/logger';
+
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '../../stores/in-app-notification';
-import PaginationWrapper from '../PaginationWrapper';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import PaginationWrapper from '../PaginationWrapper';
 
-import loggerFactory from '~/utils/logger';
+import InAppNotificationList from './InAppNotificationList';
 
-const logger = loggerFactory('growi:InAppNotificationPage');
 
+const logger = loggerFactory('growi:InAppNotificationPage');
 
-type Props = {
-  appContainer: AppContainer
-}
 
-const InAppNotificationPageBody: FC<Props> = (props) => {
-  const { appContainer } = props;
-  const limit = appContainer.config.pageLimitationXL;
+export const InAppNotificationPage: FC = () => {
   const { t } = useTranslation();
   const { mutate } = useSWRxInAppNotificationStatus();
 
+  const { data: showPageLimitationXL } = useShowPageLimitationXL();
+
+  const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
+
   const updateNotificationStatus = useCallback(async() => {
     try {
       await apiv3Post('/in-app-notification/read');
@@ -143,9 +142,4 @@ const InAppNotificationPageBody: FC<Props> = (props) => {
   );
 };
 
-const InAppNotificationPage = withUnstatedContainers(InAppNotificationPageBody, [AppContainer]);
-export default InAppNotificationPage;
-
-InAppNotificationPageBody.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
+InAppNotificationPage.displayName = 'InAppNotificationPage';

+ 92 - 0
packages/app/src/components/Layout/SearchResultLayout.module.scss

@@ -0,0 +1,92 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.on-search :global {
+  .page-wrapper {
+    padding-bottom: unset;
+  }
+
+  .search-control-include-options {
+    .card-body {
+      padding: 5px 10px;
+    }
+  }
+  .search-result-list {
+    .search-result-list-scroll {
+      // subtract the height of GrowiNavbar + (SearchControl component + other factors)
+      height: calc(100vh - ((var.$grw-navbar-height + var.$grw-navbar-border-width) + 110px));
+      overflow-y: scroll;
+
+      @include bs.media-breakpoint-down(sm) {
+        height: calc(100vh - ((var.$grw-navbar-height + var.$grw-navbar-border-width + var.$grw-navbar-bottom-height) + 123px));
+      }
+    }
+
+    .search-result-keyword {
+      font-size: 17.5px;
+      font-weight: bold;
+    }
+    .search-result-select-group {
+      > select {
+        max-width: 8rem;
+      }
+    }
+
+    // list group
+    .page-list {
+      // not show top label in search result list
+      .page-list-meta {
+        .top-label {
+          display: none;
+        }
+      }
+    }
+  }
+
+  .search-result-content {
+    .search-result-content-nav {
+      min-height: var.$grw-subnav-search-preview-min-height;
+      overflow: auto;
+
+      .grw-subnav {
+        min-height: inherit;
+      }
+    }
+
+    .search-result-content {
+      height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width));
+
+      > h2 {
+        margin-right: 10px;
+        font-size: 22px;
+        line-height: 1em;
+      }
+
+      &:first-child > h2 {
+        margin-top: 0;
+      }
+
+      .search-result-content-body-container {
+        overflow-y: auto;
+
+        .wiki {
+          padding: 16px;
+          font-size: 13px;
+        }
+      }
+    }
+  }
+}
+
+// style to apply when displaying search page
+.on-search :global {
+  // set sidebar height shown in search page
+  $search-page-sidebar-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width));
+
+  .grw-sidebar {
+    height: $search-page-sidebar-height;
+    .data-layout-container {
+      height: 100%;
+    }
+  }
+}

+ 34 - 0
packages/app/src/components/Layout/SearchResultLayout.tsx

@@ -0,0 +1,34 @@
+import React, { ReactNode } from 'react';
+
+import { BasicLayout } from '~/components/Layout/BasicLayout';
+
+import commonStyles from './SearchResultLayout.module.scss';
+
+type Props = {
+  title: string,
+  className?: string,
+  children?: ReactNode,
+}
+
+const SearchResultLayout = ({
+  children, title, className,
+}: Props): JSX.Element => {
+
+  const classNames: string[] = [];
+  if (className != null) {
+    classNames.push(className);
+  }
+
+  return (
+    <div className={`${commonStyles['on-search']}`}>
+      <BasicLayout title={title} className={classNames.join(' ')}>
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+        <div id="main" className="main search-page mt-0">
+          { children }
+        </div>
+      </BasicLayout>
+    </div>
+  );
+};
+
+export default SearchResultLayout;

+ 7 - 6
packages/app/src/components/MyDraftList/MyDraftList.jsx

@@ -3,7 +3,6 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiGet } from '~/client/util/apiv1-client';
 
@@ -105,7 +104,7 @@ class MyDraftList extends React.Component {
   }
 
   clearDraft(path) {
-    this.props.editorContainer.clearDraft(path);
+    // this.props.editorContainer.clearDraft(path);
 
     this.setState((prevState) => {
       return {
@@ -116,7 +115,7 @@ class MyDraftList extends React.Component {
   }
 
   clearAllDrafts() {
-    this.props.editorContainer.clearAllDrafts();
+    // this.props.editorContainer.clearAllDrafts();
 
     this.setState({
       drafts: [],
@@ -175,7 +174,7 @@ MyDraftList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  // editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
 const MyDraftListWrapperFC = (props) => {
@@ -183,9 +182,11 @@ const MyDraftListWrapperFC = (props) => {
   return <MyDraftList t={t} {...props} />;
 };
 
+export default MyDraftListWrapperFC;
+
 /**
  * Wrapper component for using unstated
  */
-const MyDraftListWrapper = withUnstatedContainers(MyDraftListWrapperFC, [PageContainer, EditorContainer]);
+// const MyDraftListWrapper = withUnstatedContainers(MyDraftListWrapperFC, [PageContainer, EditorContainer]);
 
-export default MyDraftListWrapper;
+// export default MyDraftListWrapper;

+ 13 - 8
packages/app/src/components/Page.tsx

@@ -6,6 +6,8 @@ import React, {
 import dynamic from 'next/dynamic';
 // import { debounce } from 'throttle-debounce';
 
+import { HtmlElementNode } from 'rehype-toc';
+
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
@@ -22,7 +24,6 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-import { HtmlElementNode } from 'rehype-toc';
 
 // TODO: import dynamically
 // import MarkdownTable from '~/client/models/MarkdownTable';
@@ -166,9 +167,9 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
     const { _id: revisionId, body: markdown } = page.revision;
 
     // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
-    // const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
+    const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
     // const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
-    // const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
+    const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
@@ -179,8 +180,8 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
         { !isGuestUser && (
           <>
-            {/* <GridEditModal ref={this.gridEditModal} /> */}
-            {/* <LinkEditModal ref={this.LinkEditModal} /> */}
+            <GridEditModal ref={this.gridEditModal} />
+            <LinkEditModal ref={this.linkEditModal} />
             {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
             {/* <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} /> */}
           </>
@@ -257,9 +258,13 @@ export const Page = (props) => {
   // }, []);
 
   if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
-    logger.warn('Some of materials are missing.', {
-      currentPage: currentPage?._id, editorMode, isGuestUser, rendererOptions,
-    });
+    const entries = Object.entries({
+      currentPage, editorMode, 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;
   }
 

+ 0 - 79
packages/app/src/components/Page/RevisionBody.jsx

@@ -1,79 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { debounce } from 'throttle-debounce';
-
-export default class RevisionBody extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    // create debounced method for rendering MathJax
-    this.renderMathJaxWithDebounce = debounce(200, this.renderMathJax);
-  }
-
-  componentDidMount() {
-    const MathJax = window.MathJax;
-    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
-      this.renderMathJaxWithDebounce();
-    }
-  }
-
-  componentDidUpdate() {
-    const MathJax = window.MathJax;
-    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxInRealtime) {
-      this.renderMathJaxWithDebounce();
-    }
-  }
-
-  UNSAFE_componentWillReceiveProps(nextProps) {
-    const MathJax = window.MathJax;
-    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
-      this.renderMathJaxWithDebounce();
-    }
-  }
-
-  renderMathJax() {
-    const MathJax = window.MathJax;
-    // Workaround MathJax Rendering (Errors still occur, but MathJax can be rendered)
-    //
-    // Reason:
-    //   Addition of draw.io Integration causes initialization conflict between MathJax of draw.io and MathJax of GROWI.
-    //   So, before MathJax is initialized, execute renderMathJaxWithDebounce again.
-    //   Avoiding initialization of MathJax of draw.io solves the problem.
-    //   refs: https://github.com/jgraph/drawio/pull/831
-    if (MathJax != null && this.element != null) {
-      MathJax.typesetPromise([this.element]);
-    }
-    else {
-      this.renderMathJaxWithDebounce();
-    }
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  render() {
-    const additionalClassName = this.props.additionalClassName || '';
-    return (
-      <div
-        ref={(elem) => {
-          this.element = elem;
-        }}
-        id="wiki"
-        className={`wiki ${additionalClassName}`}
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}
-      />
-    );
-  }
-
-}
-
-RevisionBody.propTypes = {
-  html: PropTypes.string,
-  renderMathJaxOnInit: PropTypes.bool,
-  renderMathJaxInRealtime: PropTypes.bool,
-  additionalClassName: PropTypes.string,
-};

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

@@ -10,10 +10,6 @@ import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
-// import RevisionBody from './RevisionBody';
-
-import katexStyles from '../CommonStyles/katex.module.scss';
-
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
@@ -103,7 +99,7 @@ const RevisionRenderer = React.memo((props: Props): JSX.Element => {
   return (
     <ReactMarkdown
       {...rendererOptions}
-      className={`wiki katex-container ${katexStyles['katex-container']} ${additionalClassName ?? ''}`}
+      className={`wiki ${additionalClassName ?? ''}`}
     >
       {markdown}
     </ReactMarkdown>

+ 98 - 166
packages/app/src/components/PageEditor.tsx

@@ -8,10 +8,7 @@ import { envUtils, PageGrant } from '@growi/core';
 import detectIndent from 'detect-indent';
 import { throttle, debounce } from 'throttle-debounce';
 
-import { saveAndReload } from '~/client/services/page-operation';
-
-// import EditorContainer from '~/client/services/EditorContainer';
-// import PageContainer from '~/client/services/PageContainer';
+import { saveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
@@ -34,10 +31,7 @@ import loggerFactory from '~/utils/logger';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-// import { withUnstatedContainers } from './UnstatedUtils';
-
 
-// TODO: remove this when omitting unstated is completed
 
 const logger = loggerFactory('growi:PageEditor');
 
@@ -53,117 +47,114 @@ type EditorRef = {
   terminateUploadingState: () => void,
 }
 
-type Props = {
-  // pageContainer: PageContainer,
-  // editorContainer: EditorContainer,
-
-  // isEditable: boolean,
-
-  // editorMode: string,
-  // isSlackEnabled: boolean,
-  // slackChannels: string,
-  // isMobile?: boolean,
-
-  // grant: number,
-  // grantGroupId?: string,
-  // grantGroupName?: string,
-  // mutateGrant: (grant: number) => void,
-
-  // isTextlintEnabled?: boolean,
-  // isIndentSizeForced?: boolean,
-  // indentSize?: number,
-  // mutateCurrentIndentSize: (indent: number) => void,
-};
-
 // for scrolling
 let lastScrolledDateWithCursor: Date | null = null;
 let isOriginOfScrollSyncEditor = false;
 let isOriginOfScrollSyncPreview = false;
 
-const PageEditor = React.memo((props: Props): JSX.Element => {
-  // const {
-  //   pageContainer, editorContainer,
-  // } = props;
+const PageEditor = React.memo((): JSX.Element => {
 
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-  const { data: isMobile } = useIsMobile();
-  const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
-  const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
+
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
-  const { data: currentPage } = useSWRxCurrentPage();
 
   const { data: rendererOptions } = usePreviewOptions();
 
-  const [markdown, setMarkdown] = useState<string>('');
+  const currentRevisionId = currentPage?.revision?._id;
+  const initialValue = currentPage?.revision?.body;
 
+  const markdownToSave = useRef<string>(initialValue ?? '');
+  const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue ?? '');
 
-  useEffect(() => {
-    if (currentPage != null) {
-      setMarkdown(currentPage.revision?.body);
-    }
-  }, [currentPage, currentPage?.revision?.body]);
-
-  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), []);
+  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
 
   const editorRef = useRef<EditorRef>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
-  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
-  // const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
-  //   editorContainer.saveDraft(pageContainer.state.path, markdown);
-  // }), [editorContainer, markdown, pageContainer.state.path]);
+  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, (value) => {
+    markdownToSave.current = value;
+    setMarkdownToPreview(value);
+    // Displays an unsaved warning alert
+    if (!isEnabledUnsavedWarning) {
+      mutateIsEnabledUnsavedWarning(true);
+    }
+  })), [isEnabledUnsavedWarning, mutateIsEnabledUnsavedWarning]);
+
 
   const markdownChangedHandler = useCallback((value: string): void => {
     setMarkdownWithDebounce(value);
-    // only when the first time to edit
-    // if (!pageContainer.state.revisionId) {
-    //   saveDraftWithDebounce();
-    // }
-  // }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
-
-  const saveWithShortcut = useCallback(async() => {
-    if (grantData == null) {
-      return;
+  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+    if (grantData == null || isSlackEnabled == null || currentPathname == null) {
+      logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
+      throw new Error('Some materials to save are invalid');
     }
 
-    const optionsToSave = getOptionsToSave(
-      isSlackEnabled ?? false, slackChannels,
-      grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-      pageTags || [],
+    const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
+    const grantedGroup = grantData?.grantedGroup;
+
+    const optionsToSave = Object.assign(
+      getOptionsToSave(isSlackEnabled, slackChannels, grant || 1, grantedGroup?.id, grantedGroup?.name, pageTags || []),
+      { ...opts },
     );
 
     try {
-      // disable unsaved warning
+      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId }, markdownToSave.current);
+      await mutateCurrentPage();
       mutateIsEnabledUnsavedWarning(false);
-
-      // eslint-disable-next-line no-unused-vars
-      // const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
-      logger.debug('success to save');
-
-      // pageContainer.showSuccessToastr();
-
-      // update state of EditorContainer
-      // editorContainer.setState({ tags });
     }
     catch (error) {
       logger.error('failed to save', error);
       // pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        // pageContainer.setState({
+        //   remoteRevisionId: error.data.revisionId,
+        //   remoteRevisionBody: error.data.revisionBody,
+        //   remoteRevisionUpdateAt: error.data.createdAt,
+        //   lastUpdateUser: error.data.user,
+        // });
+      }
     }
-  }, [grantData, isSlackEnabled, slackChannels, pageTags, mutateIsEnabledUnsavedWarning]);
+
+  // eslint-disable-next-line max-len
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId, mutateCurrentPage, mutateIsEnabledUnsavedWarning]);
+
+  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+    if (editorMode !== EditorMode.Editor) {
+      return;
+    }
+
+    await save(opts);
+    mutateEditorMode(EditorMode.View);
+  }, [editorMode, save, mutateEditorMode]);
+
+  const saveWithShortcut = useCallback(async() => {
+    if (editorMode !== EditorMode.Editor) {
+      return;
+    }
+
+    await save();
+
+    // TODO: show toastr
+    // pageContainer.showErrorToastr(error);
+  }, [editorMode, save]);
 
 
   /**
@@ -221,7 +212,6 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  // }, [editorMode, mutateGrant, pageContainer]);
   }, [currentPagePath, mutateGrant, pageId]);
 
 
@@ -318,6 +308,16 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
   const scrollEditorByPreviewScrollWithThrottle = useMemo(() => throttle(20, scrollEditorByPreviewScroll), [scrollEditorByPreviewScroll]);
 
 
+  // initialize
+  useEffect(() => {
+    if (initialValue == null) {
+      return;
+    }
+    markdownToSave.current = initialValue;
+    setMarkdownToPreview(initialValue);
+    mutateIsEnabledUnsavedWarning(false);
+  }, [initialValue, mutateIsEnabledUnsavedWarning]);
+
   // initial caret line
   useEffect(() => {
     if (editorRef.current != null) {
@@ -342,68 +342,14 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
     };
   }, []);
 
-
-  const saveAndReloadHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
-    if (editorMode !== EditorMode.Editor) {
-      return;
-    }
-
-    const grant = grantData?.grant || PageGrant.GRANT_PUBLIC;
-    const grantedGroup = grantData?.grantedGroup;
-
-    if (isSlackEnabled == null || currentPathname == null) {
-      return;
-    }
-
-    let optionsToSave;
-
-    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant || 1, grantedGroup?.id, grantedGroup?.name, pageTags || []);
-
-    if (opts != null) {
-      optionsToSave = Object.assign(currentOptionsToSave, {
-        ...opts,
-      });
-    }
-    else {
-      optionsToSave = currentOptionsToSave;
-    }
-
-    try {
-      await saveAndReload(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentPage?.revision?._id }, markdown);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      // pageContainer.showErrorToastr(error);
-      if (error.code === 'conflict') {
-        // pageContainer.setState({
-        //   remoteRevisionId: error.data.revisionId,
-        //   remoteRevisionBody: error.data.revisionBody,
-        //   remoteRevisionUpdateAt: error.data.createdAt,
-        //   lastUpdateUser: error.data.user,
-        // });
-      }
-    }
-  }, [currentPage?.revision?._id,
-      currentPagePath,
-      currentPathname,
-      editorMode,
-      grantData?.grant,
-      grantData?.grantedGroup,
-      isSlackEnabled,
-      markdown,
-      pageId,
-      pageTags,
-      slackChannels,
-  ]);
-
-  // set handler to save and reload Page
+  // set handler to save and return to View
   useEffect(() => {
-    globalEmitter.on('saveAndReload', saveAndReloadHandler);
+    globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
 
     return function cleanup() {
-      globalEmitter.removeListener('saveAndReload', saveAndReloadHandler);
+      globalEmitter.removeListener('saveAndReturnToView', saveAndReturnToViewHandler);
     };
-  }, [saveAndReloadHandler]);
+  }, [saveAndReturnToViewHandler]);
 
   // set handler to focus
   useEffect(() => {
@@ -412,27 +358,21 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [editorMode]);
 
+  // Unnecessary code. Delete after PageEditor and PageEditorByHackmd implementation has completed. -- 2022.09.06 Yuki Takei
+  //
   // set handler to update editor value
-  useEffect(() => {
-    const handler = (markdown) => {
-      if (editorRef.current != null) {
-        editorRef.current.setValue(markdown);
-      }
-    };
-    globalEmitter.on('updateEditorValue', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('updateEditorValue', handler);
-    };
-  }, []);
-
-  // Displays an alert if there is a difference with pageContainer's markdown
   // useEffect(() => {
-  //   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  //   if (pageContainer.state.markdown! !== markdown) {
-  //     mutateIsEnabledUnsavedWarning(true);
-  //   }
-  // }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
+  //   const handler = (markdown) => {
+  //     if (editorRef.current != null) {
+  //       editorRef.current.setValue(markdown);
+  //     }
+  //   };
+  //   globalEmitter.on('updateEditorValue', handler);
+
+  //   return function cleanup() {
+  //     globalEmitter.removeListener('updateEditorValue', handler);
+  //   };
+  // }, []);
 
   // Detect indent size from contents (only when users are allowed to change it)
   // useEffect(() => {
@@ -456,13 +396,12 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const isUploadable = isUploadableImage || isUploadableFile;
 
-
   return (
     <div className="d-flex flex-wrap">
       <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
         <Editor
           ref={editorRef}
-          value={markdown}
+          value={initialValue}
           isUploadable={isUploadable}
           isUploadableFile={isUploadableFile}
           isTextlintEnabled={isTextlintEnabled}
@@ -471,16 +410,15 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
           onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
           onChange={markdownChangedHandler}
           onUpload={uploadHandler}
-          onSave={() => saveWithShortcut()}
+          onSave={saveWithShortcut}
         />
       </div>
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
           ref={previewRef}
           rendererOptions={rendererOptions}
-          markdown={markdown}
+          markdown={markdownToPreview}
           pagePath={currentPagePath}
-          renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
         />
       </div>
@@ -495,10 +433,4 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
 });
 PageEditor.displayName = 'PageEditor';
 
-/**
-   * Wrapper component for using unstated
-   */
-// const PageEditorWrapper = withUnstatedContainers(PageEditor, [PageContainer, EditorContainer]);
-
-// export default PageEditorWrapper;
 export default PageEditor;

+ 1 - 0
packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
+
 import { ICodeMirror } from 'react-codemirror2';
 
 

+ 10 - 21
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import { createValidator } from '@growi/codemirror-textlint';
-import * as codemirror from 'codemirror';
+import { commands } from 'codemirror';
 import { JSHINT } from 'jshint';
 import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
@@ -20,10 +20,10 @@ import CommentMentionHelper from './CommentMentionHelper';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
-// import GridEditModal from './GridEditModal';
+import GridEditModal from './GridEditModal';
 import geu from './GridEditorUtil';
 // import HandsontableModal from './HandsontableModal';
-// import LinkEditModal from './LinkEditModal';
+import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
 import markdownListUtil from './MarkdownListUtil';
@@ -39,14 +39,6 @@ import styles from './CodeMirrorEditor.module.scss';
 window.JSHINT = JSHINT;
 window.kuromojin = { dicPath: '/static/dict' };
 
-// set save handler
-codemirror.commands.save = (instance) => {
-  if (instance.codeMirrorEditor != null) {
-    instance.codeMirrorEditor.dispatchSave();
-  }
-};
-// set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
-window.CodeMirror = require('codemirror');
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
@@ -107,7 +99,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.logger = loggerFactory('growi:PageEditor:CodeMirrorEditor');
 
     this.state = {
-      value: this.props.value,
       isGfmMode: this.props.isGfmMode,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -252,7 +243,6 @@ class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   setValue(newValue) {
-    this.setState({ value: newValue });
     this.getCodeMirror().getDoc().setValue(newValue);
   }
 
@@ -508,7 +498,7 @@ class CodeMirrorEditor extends AbstractEditor {
    */
   handleEnterKey() {
     if (!this.state.isGfmMode) {
-      codemirror.commands.newlineAndIndent(this.getCodeMirror());
+      commands.newlineAndIndent(this.getCodeMirror());
       return;
     }
 
@@ -791,11 +781,11 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showGridEditorHandler() {
-    // this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
+    this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   }
 
   showLinkEditHandler() {
-    // this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
+    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
   showHandsonTableHandler() {
@@ -1002,8 +992,6 @@ class CodeMirrorEditor extends AbstractEditor {
           //   editor.on('paste', this.pasteHandler);
           //   editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
           // }}
-          // temporary set props.value
-          // value={this.state.value}
           value={this.props.value}
           options={{
             indentUnit: this.props.indentSize,
@@ -1056,14 +1044,15 @@ class CodeMirrorEditor extends AbstractEditor {
         { this.renderCheatsheetOverlay() }
         { this.renderEmojiPicker() }
 
-        {/* <GridEditModal
+        <GridEditModal
           ref={this.gridEditModal}
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
-        /> */}
-        {/* <LinkEditModal
+        />
+        <LinkEditModal
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
+        {/*
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}

+ 1 - 1
packages/app/src/components/PageEditor/GridEditModal.jsx

@@ -190,7 +190,7 @@ class GridEditModal extends React.Component {
   render() {
     const { t } = this.props;
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className={`${styles['grw-grid-edit-modal']}`}>
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className={`grw-grid-edit-modal ${styles['grw-grid-edit-modal']}`}>
         <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
           {t('grid_edit.create_bootstrap_4_grid')}
         </ModalHeader>

+ 1 - 1
packages/app/src/components/PageEditor/GridEditModal.module.scss

@@ -1,6 +1,6 @@
 @use '~/styles/bootstrap/init' as bs;
 
-.grw-grid-edit-modal {
+.grw-grid-edit-modal :global {
   .desktop-preview,
   .tablet-preview,
   .mobile-preview {

+ 4 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -24,6 +24,8 @@ import SearchTypeahead from '../SearchTypeahead';
 
 import Preview from './Preview';
 
+import styles from './LinkEditPreview.module.scss';
+
 
 class LinkEditModal extends React.PureComponent {
 
@@ -302,13 +304,13 @@ class LinkEditModal extends React.PureComponent {
                 autoFocus
               />
               <div className="d-none d-sm-block input-group-append">
-                <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
+                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
                   <PagePreviewIcon />
                 </button>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                   <PopoverBody>
                     {this.state.markdown != null && pagePath != null
-                    && <div className="linkedit-preview">
+                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
                       <Preview markdown={this.state.markdown} pagePath={pagePath} />
                     </div>
                     }

+ 0 - 0
packages/app/src/styles/_linkedit-preview.scss → packages/app/src/components/PageEditor/LinkEditPreview.module.scss


+ 0 - 25
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -199,30 +199,6 @@ const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDr
     );
   }, [editorSettings, update, t]);
 
-  const renderRealtimeMathJaxMenuItem = useCallback(() => {
-    if (editorSettings == null) {
-      return <></>;
-    }
-
-    const isActive = editorSettings.renderMathJaxInRealtime;
-
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={() => update({ renderMathJaxInRealtime: !isActive })}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
-          <span className="menuitem-label">MathJax Rendering</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }, [editorSettings, update]);
-
   const renderRealtimeDrawioMenuItem = useCallback(() => {
     if (editorSettings == null) {
       return <></>;
@@ -324,7 +300,6 @@ const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDr
 
         <DropdownMenu>
           {renderActiveLineMenuItem()}
-          {renderRealtimeMathJaxMenuItem()}
           {renderRealtimeDrawioMenuItem()}
           {renderMarkdownTableAutoFormattingMenuItem()}
           {renderIsTextlintEnabledMenuItem()}

+ 0 - 1
packages/app/src/components/PageEditor/Preview.tsx

@@ -12,7 +12,6 @@ type Props = {
   rendererOptions: RendererOptions,
   markdown?: string,
   pagePath?: string | null,
-  renderMathJaxOnInit?: boolean,
   onScroll?: (scrollTop: number) => void,
 }
 

+ 1 - 1
packages/app/src/components/PageEditor/ScrollSyncHelper.js

@@ -13,7 +13,7 @@ class ScrollSyncHelper {
     let elements;
     if (!elements) {
       elements = Array.prototype.map.call(
-        parentElement.getElementsByClassName('code-line'),
+        parentElement.getElementsByClassName('has-data-line'),
         (element) => {
           const line = +element.getAttribute('data-line');
           return { element, line };

+ 1 - 1
packages/app/src/components/PageHistory/Revision.jsx

@@ -58,7 +58,7 @@ export default class Revision extends React.Component {
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />
             <br className="d-xl-none d-block" />
-            <a className="ml-xl-3" href={`?revision=${revision._id}`}>
+            <a className="ml-xl-3" href={`?revisionId=${revision._id}`}>
               <i className="icon-login"></i> { t('Go to this version') }
             </a>
           </div>

+ 2 - 2
packages/app/src/components/PageHistory/RevisionDiff.jsx

@@ -51,14 +51,14 @@ class RevisionDiff extends React.Component {
             <div className="row">
               <div className="col comparison-source-wrapper pt-1 px-0">
                 <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
-                <a href={`?revision=${previousRevision._id}`} className="ml-3">
+                <a href={`?revisionId=${previousRevision._id}`} className="ml-3">
                   <i className="icon-login"></i>
                 </a>
 
               </div>
               <div className="col comparison-target-wrapper pt-1">
                 <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
-                <a href={`?revision=${currentRevision._id}`} className="ml-3">
+                <a href={`?revisionId=${currentRevision._id}`} className="ml-3">
                   <i className="icon-login"></i>
                 </a>
               </div>

+ 2 - 2
packages/app/src/components/SavePageControls.tsx

@@ -48,14 +48,14 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
     mutateIsEnabledUnsavedWarning(false);
 
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReload');
+    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
   }, [mutateIsEnabledUnsavedWarning]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // disable unsaved warning
     mutateIsEnabledUnsavedWarning(false);
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReload', { overwriteScopesOfDescendants: true });
+    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
   }, [mutateIsEnabledUnsavedWarning]);
 
 

+ 36 - 0
packages/app/src/components/SearchForm.module.scss

@@ -0,0 +1,36 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-search-table {
+  caption {
+    display: table-header-group;
+  }
+}
+
+@include bs.media-breakpoint-down(sm) {
+  .grw-search-table {
+    th {
+      text-align: right;
+    }
+
+    td {
+      overflow-wrap: anywhere;
+      white-space: normal !important;
+    }
+
+    @include bs.media-breakpoint-down(xs) {
+      th,
+      td {
+        display: block;
+      }
+
+      th {
+        text-align: left;
+      }
+
+      td {
+        padding-top: 0 !important;
+        border-top: none !important;
+      }
+    }
+  }
+}

+ 3 - 1
packages/app/src/components/SearchForm.tsx

@@ -11,6 +11,8 @@ import { IPageWithSearchMeta } from '~/interfaces/search';
 
 import SearchTypeahead from './SearchTypeahead';
 
+import styles from './SearchForm.module.scss';
+
 
 type SearchFormHelpProps = {
   isReachable: boolean,
@@ -31,7 +33,7 @@ const SearchFormHelp: FC<SearchFormHelpProps> = React.memo((props: SearchFormHel
   }
 
   return (
-    <table className="table grw-search-table search-help m-0">
+    <table className={`${styles['grw-search-table']} table grw-search-table search-help m-0`}>
       <caption className="text-left text-primary p-2">
         <h5 className="h6"><i className="icon-magnifier pr-2 mb-2" />{ t('search_help.title') }</h5>
       </caption>

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -99,7 +99,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       </div>
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-        <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />
+        {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
         <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>

+ 13 - 0
packages/app/src/components/Theme/utils/ThemeInjector.tsx

@@ -1,6 +1,8 @@
 
 import React from 'react';
 
+import { useIsomorphicLayoutEffect } from 'usehooks-ts';
+
 type Props = {
   children: JSX.Element,
   className: string,
@@ -9,6 +11,17 @@ type Props = {
 
 export const ThemeInjector = ({ children, className: themeClassName, bgImageNode }: Props): JSX.Element => {
   const className = `${children.props.className ?? ''} ${themeClassName}`;
+
+  // add class name to <body>
+  useIsomorphicLayoutEffect(() => {
+    document.body.classList.add(themeClassName);
+
+    // clean up
+    return () => {
+      document.body.classList.remove(themeClassName);
+    };
+  });
+
   return React.cloneElement(children, { className }, [
     <div key="grw-bg-image-wrapper" className="grw-bg-image-wrapper">{bgImageNode}</div>,
     children.props.children,

+ 62 - 54
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -1,81 +1,89 @@
 import React, {
-  forwardRef, ReactNode, Ref,
+  useCallback, useRef, MutableRefObject,
 } from 'react';
 
-import { Editor } from 'codemirror';
+import { commands, Editor } from 'codemirror';
 import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
 
-import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+// set save handler
+// CommandActions in @types/codemirror does not include 'save' but actualy exists
+// https://codemirror.net/5/doc/manual.html#commands
+(commands as any).save = (instance) => {
+  if (instance.codeMirrorEditor != null) {
+    instance.codeMirrorEditor.dispatchSave();
+  }
+};
 
 window.CodeMirror = require('codemirror');
 require('codemirror/addon/display/placeholder');
 require('~/client/util/codemirror/gfm-growi.mode');
 
-export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+export interface UncontrolledCodeMirrorProps extends ICodeMirror {
   value: string;
-  options?: ICodeMirror['options'];
   isGfmMode?: boolean;
   lineNumbers?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
 }
 
-interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
-  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
-}
-
-export class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+export const UncontrolledCodeMirror = React.forwardRef<CodeMirror|null, UncontrolledCodeMirrorProps>((props, forwardedRef): JSX.Element => {
 
-  editor: Editor;
+  const wrapperRef = useRef<CodeMirror|null>();
 
-  // wrapperRef: RefObject<any>;
+  const editorRef = useRef<Editor>();
 
-  constructor(props: UncontrolledCodeMirrorCoreProps) {
-    super(props);
-    this.editorDidMount = this.editorDidMount.bind(this);
-    this.editorWillUnmount = this.editorWillUnmount.bind(this);
-  }
+  const editorDidMountHandler = useCallback((editor: Editor): void => {
+    editorRef.current = editor;
+  }, []);
 
-  editorDidMount(e: Editor): void {
-    this.editor = e;
-  }
-
-  editorWillUnmount(): void {
+  const editorWillUnmountHandler = useCallback((): void => {
     // workaround to fix editor duplicating by https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
-    (this.editor as any).display.wrapper.remove();
-  }
+    if (editorRef.current != null) {
+      (editorRef.current as any).display.wrapper.remove();
+    }
+    if (wrapperRef.current != null) {
+      (wrapperRef.current as any).hydrated = false;
+    }
+  }, []);
+
+  const {
+    value, lineNumbers, options,
+    ...rest
+  } = props;
+
+  // default true
+  const isGfmMode = rest.isGfmMode ?? true;
 
-  override render(): ReactNode {
-
-    const {
-      value, isGfmMode, lineNumbers, options, forwardedRef,
-      ...rest
-    } = this.props;
-
-    return (
-      <CodeMirror
-        ref={forwardedRef}
-        value={value}
-        options={{
-          lineNumbers: lineNumbers ?? true,
-          mode: isGfmMode ? 'gfm-growi' : undefined,
-          tabSize: 4,
-          ...options,
-        }}
-        editorDidMount={this.editorDidMount}
-        editorWillUnmount={this.editorWillUnmount}
-        {...rest}
-      />
-    );
-  }
-
-}
-
-export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => {
   return (
-    <UncontrolledCodeMirrorCore
-      {...props}
-      forwardedRef={ref}
+    <CodeMirror
+      ref={(elem) => {
+        // register to wrapperRef
+        wrapperRef.current = elem;
+        // register to forwardedRef
+        if (forwardedRef != null) {
+          if (typeof forwardedRef === 'function') {
+            forwardedRef(elem);
+          }
+          else {
+            (forwardedRef as MutableRefObject<CodeMirror|null>).current = elem;
+          }
+        }
+      }}
+      value={value}
+      options={{
+        lineNumbers: lineNumbers ?? true,
+        mode: isGfmMode ? 'gfm-growi' : undefined,
+        tabSize: 4,
+        ...options,
+      }}
+      editorDidMount={editorDidMountHandler}
+      editorWillUnmount={editorWillUnmountHandler}
+      {...rest}
     />
   );
+
 });
 
 UncontrolledCodeMirror.displayName = 'UncontrolledCodeMirror';

+ 0 - 1
packages/app/src/interfaces/editor-settings.ts

@@ -24,7 +24,6 @@ export interface IEditorSettings {
   theme: undefined | string,
   keymapMode: undefined | KeyMapMode,
   styleActiveLine: boolean,
-  renderMathJaxInRealtime: boolean,
   renderDrawioInRealtime: boolean,
   autoFormatMarkdownTable: boolean,
   textlintSettings: undefined | ITextlintSettings;

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

@@ -149,7 +149,6 @@ type Props = CommonProps & {
   // hasSlackConfig: boolean,
   // drawioUri: string,
   hackmdUri: string,
-  // mathJax: string,
   // noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
@@ -217,7 +216,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useHasSlackConfig(props.hasSlackConfig);
   // useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
-  // useMathJax(props.mathJax);
   // useNoCdn(props.noCdn);
   // useIndentSize(props.adminPreferredIndentSize);
   useDisableLinkSharing(props.disableLinkSharing);
@@ -284,7 +282,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
       <Head>
         {/*
         {renderScriptTagByName('drawio-viewer')}
-        {renderScriptTagByName('mathjax')}
         {renderScriptTagByName('highlight-addons')}
         {renderHighlightJsStyleTag(props.highlightJsStyle)}
         */}
@@ -520,7 +517,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
   // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
-  // props.mathJax = configManager.getConfig('crowi', 'app:mathJax');
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');

+ 7 - 13
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -21,11 +21,12 @@ import {
   useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 
-
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
 
+const SearchResultLayout = dynamic(() => import('~/components/Layout/SearchResultLayout'), { ssr: false });
+
 type Props = CommonProps & {
   currentUser: IUser,
 
@@ -77,19 +78,12 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
         {renderScriptTagByName('highlight-addons')}
         */}
       </Head>
-      <div className="on-search">
-        <BasicLayout title={useCustomTitle(props, 'GROWI')}>
-
-          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-          <div id="main" className="main search-page mt-0">
-
-            <div id="private-regacy-pages">
-              <PrivateLegacyPages />
-            </div>
 
-          </div>
-        </BasicLayout>
-      </div>
+      <SearchResultLayout title={useCustomTitle(props, 'GROWI')}>
+        <div id="private-regacy-pages">
+          <PrivateLegacyPages />
+        </div>
+      </SearchResultLayout>
     </>
   );
 };

+ 8 - 12
packages/app/src/pages/_search.page.tsx

@@ -28,6 +28,8 @@ import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
 
+const SearchResultLayout = dynamic(() => import('~/components/Layout/SearchResultLayout'), { ssr: false });
+
 type Props = CommonProps & {
   currentUser: IUser,
 
@@ -87,20 +89,14 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
         {renderScriptTagByName('highlight-addons')}
         */}
       </Head>
-      <div className="on-search">
-        <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-
-          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-          <div id="main" className="main search-page mt-0">
 
-            <div id="search-page">
-              <SearchPage />
-            </div>
+      <SearchResultLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+        <div id="search-page">
+          <SearchPage />
+        </div>
+      </SearchResultLayout>
 
-          </div>
-          <PutbackPageModal />
-        </BasicLayout>
-      </div>
+      <PutbackPageModal />
     </>
   );
 };

+ 52 - 17
packages/app/src/pages/me.page.tsx → packages/app/src/pages/me/[[...path]].page.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import {
   IUser, IUserHasId,
@@ -12,27 +12,25 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 
+import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import {
-  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
-} from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-
-import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList,
-} from '../stores/context';
+  useRegistrationWhiteList, useShowPageLimitationXL,
+} from '~/stores/context';
+import {
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
-} from './utils/commons';
+} from '../utils/commons';
 
 
 const logger = loggerFactory('growi:pages:me');
@@ -44,16 +42,55 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   userUISettings?: IUserUISettings
   sidebarConfig: ISidebarConfig,
+  showPageLimitationXL: number,
 
   // config
   registrationWhiteList: string[],
 };
 
+const PersonalSettings = dynamic(() => import('~/components/Me/PersonalSettings'), { ssr: false });
+// const MyDraftList = dynamic(() => import('~/components/MyDraftList/MyDraftList'), { ssr: false });
+const InAppNotificationPage = dynamic(
+  () => import('~/components/InAppNotification/InAppNotificationPage').then(mod => mod.InAppNotificationPage), { ssr: false },
+);
+
 const MePage: NextPage<Props> = (props: Props) => {
+  const router = useRouter();
+  const { t } = useTranslation();
+  const { path } = router.query;
+  const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
+
+  const mePagesMap = useMemo(() => {
+    return {
+      'personal-settings': {
+        title: t('User Settings'),
+        component: <PersonalSettings />,
+      },
+      // drafts: {
+      //   title: t('My Drafts'),
+      //   component: <MyDraftList />,
+      // },
+      'all-in-app-notifications': {
+        title: t('in_app_notification.notification_list'),
+        component: <InAppNotificationPage />,
+      },
+    };
+  }, [t]);
+
+  const getTargetPageToRender = (pagesMap, keys): {title: string, component: JSX.Element} => {
+    return keys.reduce((pagesMap, key) => {
+      return pagesMap[key];
+    }, pagesMap);
+  };
+
+  const targetPage = getTargetPageToRender(mePagesMap, pagePathKeys);
+
   useCurrentUser(props.currentUser ?? null);
 
   useRegistrationWhiteList(props.registrationWhiteList);
 
+  useShowPageLimitationXL(props.showPageLimitationXL);
+
   // commons
   useCsrfToken(props.csrfToken);
 
@@ -68,9 +105,6 @@ const MePage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
-  const { t } = useTranslation();
-
-  const PersonalSettings = dynamic(() => import('~/components/Me/PersonalSettings'), { ssr: false });
 
   return (
     <>
@@ -78,7 +112,7 @@ const MePage: NextPage<Props> = (props: Props) => {
 
         <header className="py-3">
           <div className="container-fluid">
-            <h1 className="title">{t('User Settings')}</h1>
+            <h1 className="title">{ targetPage.title }</h1>
           </div>
         </header>
 
@@ -86,7 +120,7 @@ const MePage: NextPage<Props> = (props: Props) => {
 
         <div id="main" className='main'>
           <div id="content-main" className="content-main grw-container-convertible">
-            <PersonalSettings />
+            {targetPage.component}
           </div>
         </div>
 
@@ -120,6 +154,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
 
+  props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
+
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
@@ -143,7 +179,6 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   const result = await getServerSideCommonProps(context);
 
-
   // check for presence
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
   if (!('props' in result)) {

+ 0 - 1
packages/app/src/server/models/editor-settings.ts

@@ -26,7 +26,6 @@ const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsMo
   theme: { type: String },
   keymapMode: { type: String },
   styleActiveLine: { type: Boolean, default: false },
-  renderMathJaxInRealtime: { type: Boolean, default: true },
   renderDrawioInRealtime: { type: Boolean, default: true },
   autoFormatMarkdownTable: { type: Boolean, default: true },
   textlintSettings: textlintSettingsSchema,

+ 2 - 3
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -117,7 +117,6 @@ module.exports = (crowi) => {
       body('theme').optional().isString(),
       body('keymapMode').optional().isString(),
       body('styleActiveLine').optional().isBoolean(),
-      body('renderMathJaxInRealtime').optional().isBoolean(),
       body('renderDrawioInRealtime').optional().isBoolean(),
       body('autoFormatMarkdownTable').optional().isBoolean(),
       body('textlintSettings.neverAskBeforeDownloadLargeFiles').optional().isBoolean(),
@@ -540,12 +539,12 @@ module.exports = (crowi) => {
     const { body } = req;
 
     const {
-      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime, autoFormatMarkdownTable,
+      theme, keymapMode, styleActiveLine, renderDrawioInRealtime, autoFormatMarkdownTable,
       textlintSettings,
     } = body;
 
     const document = {
-      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime, autoFormatMarkdownTable,
+      theme, keymapMode, styleActiveLine, renderDrawioInRealtime, autoFormatMarkdownTable,
     };
 
     if (textlintSettings != null) {

+ 5 - 5
packages/app/src/server/routes/index.js

@@ -207,13 +207,13 @@ module.exports = function(crowi, app) {
   // app.get('/tags'                     , loginRequired, tag.showPage);
   app.get('/tags', loginRequired, next.delegateToNext);
 
-  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
+  app.get('/me/*'                                 , loginRequiredStrictly, injectUserUISettings, next.delegateToNext);
   // external-accounts
   // my in-app-notifications
-  app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, allInAppNotifications.list);
-  app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
-  // my drafts
-  app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
+  // app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, allInAppNotifications.list);
+  // app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
+  // // my drafts
+  // app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
 
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);

+ 13 - 4
packages/app/src/services/renderer/rehype-plugins/add-class.ts

@@ -1,6 +1,7 @@
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // Re-implemeted in TypeScript.
 import { selectAll, HastNode, Element } from 'hast-util-select';
+import { Properties } from 'hast-util-select/lib/types';
 import { Plugin } from 'unified';
 
 export type SelectorName = string; // e.g. 'h1'
@@ -8,9 +9,7 @@ export type ClassName = string; // e.g. 'header'
 export type Additions = Record<SelectorName, ClassName>;
 export type AdditionsEntry = [SelectorName, ClassName];
 
-const generateWriter = (className: string) => (element: Element) => {
-  const { properties } = element;
-
+export const addClassToProperties = (properties: Properties | undefined, className: string): void => {
   if (properties == null) {
     return;
   }
@@ -20,9 +19,19 @@ const generateWriter = (className: string) => (element: Element) => {
     return;
   }
 
+  if (Array.isArray(properties.className)) {
+    properties.className.push(className);
+    return;
+  }
+
   properties.className += ` ${className}`;
 };
 
+const generateWriter = (className: string) => (element: Element) => {
+  const { properties } = element;
+  addClassToProperties(properties, className);
+};
+
 const adder = (entry: AdditionsEntry) => {
   const [selectorName, className] = entry;
   const writer = generateWriter(className);
@@ -30,7 +39,7 @@ const adder = (entry: AdditionsEntry) => {
   return (node: HastNode) => selectAll(selectorName, node).forEach(writer);
 };
 
-export const addClass: Plugin<[Additions]> = (additions) => {
+export const rehypePlugin: Plugin<[Additions]> = (additions) => {
   const adders = Object.entries(additions).map(adder);
 
   return node => adders.forEach(a => a(node as HastNode));

+ 19 - 5
packages/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts

@@ -1,17 +1,31 @@
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { Element } from 'hast-util-select';
 import { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
-const REGEXP_TARGET_TAGNAMES = new RegExp(/h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul/);
+import { addClassToProperties } from './add-class';
 
-export const addLineNumberAttribute: Plugin = () => {
+const REGEXP_TARGET_TAGNAMES = new RegExp(/^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul)$/);
+
+export const rehypePlugin: Plugin = () => {
   return (tree) => {
     visit(tree, 'element', (node: Element) => {
       if (REGEXP_TARGET_TAGNAMES.test(node.tagName as string)) {
-        if (node.properties != null) {
-          node.properties['data-line'] = node.position?.start.line;
-        }
+        const properties = node.properties ?? {};
+
+        // add class
+        addClassToProperties(properties, 'has-data-line');
+        // add attribute
+        properties['data-line'] = node.position?.start.line;
+
+        node.properties = properties;
       }
     });
   };
 };
+
+export const sanitizeOption: SanitizeOption = {
+  attributes: {
+    '*': ['data-line'],
+  },
+};

+ 10 - 12
packages/app/src/services/renderer/renderer.tsx

@@ -27,8 +27,8 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { addClass } from './rehype-plugins/add-class';
-import { addLineNumberAttribute } from './rehype-plugins/add-line-number-attribute';
+import * as addClass from './rehype-plugins/add-class';
+import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attribute';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
@@ -245,8 +245,10 @@ export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehy
 const commonSanitizeOption: SanitizeOption = deepmerge(
   sanitizeDefaultSchema,
   {
+    tagNames: ['svg', 'path'],
     attributes: {
-      '*': ['class', 'className'],
+      path: ['d'],
+      '*': ['class', 'className', 'style'],
     },
   },
 );
@@ -290,7 +292,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
-      [addClass, {
+      [addClass.rehypePlugin, {
         table: 'table table-bordered',
       }],
     ],
@@ -439,7 +441,7 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   rehypePlugins.push(
     katex,
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
-    addLineNumberAttribute,
+    addLineNumberAttribute.rehypePlugin,
     // [autoLinkHeadings, {
     //   behavior: 'append',
     // }]
@@ -448,20 +450,16 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   const sanitizeOption = deepmerge(
     commonSanitizeOption,
     lsxGrowiPlugin.sanitizeOption,
-    {
-      attributes: {
-        '*': ['data-line'],
-      },
-    },
+    addLineNumberAttribute.sanitizeOption,
   );
-  rehypePlugins.push([sanitize, sanitizeOption]);
+  // rehypePlugins.push([sanitize, sanitizeOption]);
 
   // add components
   if (components != null) {
     components.lsx = props => <Lsx {...props} />;
   }
 
-  verifySanitizePlugin(options);
+  // verifySanitizePlugin(options);
   return options;
 };
 

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -255,6 +255,10 @@ export const useIsUploadableFile = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR('isUploadableFile', initialData);
 };
 
+export const useShowPageLimitationXL = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('showPageLimitationXL', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

+ 6 - 5
packages/app/src/styles/style-next.scss

@@ -5,23 +5,24 @@
 // // override codemirror
 // @import 'override-codemirror';
 
+// react-bootstrap-typeahead
 @import '~react-bootstrap-typeahead/css/Typeahead';
 @import 'override-rbt';
 
-// import SimpleBar styles
+// SimpleBar
 @import '~simplebar/dist/simplebar.min.css';
-
-// override simplebar-react styles
 @import 'override-simplebar';
 
+// KaTeX
+@import '~katex/dist/katex.min';
+
 // icons
 @import '~simple-line-icons';
 @import '~material-icons/iconfont/filled';
 @import '~font-awesome';
 @import '~@icon/themify-icons/themify-icons';
 
-
-// // atoms
+// atoms
 @import 'atoms/buttons';
 // @import 'atoms/code';
 // @import 'atoms/nav';