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

Merge branch 'master' into feat/growi-bot

yusuketk 5 лет назад
Родитель
Сommit
1f6eb89e70
29 измененных файлов с 668 добавлено и 309 удалено
  1. 13 6
      CHANGES.md
  2. 1 1
      package.json
  3. 16 4
      resource/locales/en_US/translation.json
  4. 16 4
      resource/locales/ja_JP/translation.json
  5. 17 5
      resource/locales/zh_CN/translation.json
  6. 17 1
      src/client/js/app.jsx
  7. 24 0
      src/client/js/components/Page/DuplicatedAlert.jsx
  8. 22 0
      src/client/js/components/Page/RedirectedAlert.jsx
  9. 22 0
      src/client/js/components/Page/RenamedAlert.jsx
  10. 2 2
      src/client/js/components/PageAccessoriesModal.jsx
  11. 20 10
      src/client/js/components/PageHistory.jsx
  12. 0 133
      src/client/js/components/PageHistory/PageRevisionList.jsx
  13. 155 0
      src/client/js/components/PageHistory/PageRevisionTable.jsx
  14. 15 45
      src/client/js/components/PageHistory/Revision.jsx
  15. 122 0
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  16. 3 0
      src/client/js/components/StaffCredit/Contributor.js
  17. 3 0
      src/client/js/services/EditorContainer.js
  18. 5 12
      src/client/js/services/PageHistoryContainer.js
  19. 128 0
      src/client/js/services/RevisionComparerContainer.js
  20. 5 2
      src/client/styles/scss/_on-edit.scss
  21. 4 0
      src/client/styles/scss/_page-accessories-modal.scss
  22. 33 0
      src/client/styles/scss/_page-history.scss
  23. 0 68
      src/client/styles/scss/_page.scss
  24. 1 0
      src/client/styles/scss/style-app.scss
  25. 4 2
      src/server/routes/apiv3/app-settings.js
  26. 11 1
      src/server/routes/apiv3/revisions.js
  27. 5 5
      src/server/service/page.js
  28. 1 1
      src/server/service/search-delegator/elasticsearch.js
  29. 3 7
      src/server/views/widget/page_alerts.html

+ 13 - 6
CHANGES.md

@@ -1,15 +1,22 @@
 # CHANGES
 # CHANGES
 
 
-## v4.2.8-RC
+## v4.2.9-RC
 
 
-* Fix: Pass app title value through the XSS filter
+* Feature: Comparing revisions
+* Improvement: Memory consumption when re-indexing for full text searching
+* Improvement: Site URL settings valildation
+* Fix: Screen transition without displaying notice on browsers except Chrome
+## v4.2.8
+
+* Improvement: Performance for pages to rename/duplicate/delete/revert pages
+* Fix: Preview scrollbar doesn't sync to editor
+    * Introduced by v4.2.6
+* Fix: Failed to save temporaryUrlCached with using gcs
+    * Introduced by v4.2.3
 * Fix: Fixed not being able to update ses settings
 * Fix: Fixed not being able to update ses settings
     * Introduced by v4.2.0
     * Introduced by v4.2.0
 * Fix: Fixed the display of updtedAt and createdAt being reversed
 * Fix: Fixed the display of updtedAt and createdAt being reversed
-* Improvement: Improved page control performance with stream and bulk
-    * rename, duplicate, delete, deleteCompletely, revrtDeleted
-* Fix: Failed to save temporaryUrlCached with using gcs
-    * Introduced by v4.2.3
+* Fix: Pass app title value through the XSS filter
 
 
 ## v4.2.7
 ## v4.2.7
 
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.2.8-RC",
+  "version": "4.2.9-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 16 - 4
resource/locales/en_US/translation.json

@@ -76,7 +76,6 @@
   "Go to this version": "View this version",
   "Go to this version": "View this version",
   "View diff": "View diff",
   "View diff": "View diff",
   "No diff": "No diff",
   "No diff": "No diff",
-  "Shrink versions that have no diffs": "Shrink versions that have no diffs",
   "User ID": "User ID",
   "User ID": "User ID",
   "User Information": "User information",
   "User Information": "User information",
   "Basic Info": "Basic info",
   "Basic Info": "Basic info",
@@ -292,9 +291,12 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "This is not the current version.",
       "version": "This is not the current version.",
-      "moved": "This page was moved from <code>%s</code>",
-      "redirected": "You are redirected from <code>%s</code>",
-      "duplicated": "This page was duplicated from <code>%s</code>",
+      "moved": "This page was moved from",
+      "moved_period": ".",
+      "redirected": "You are redirected from",
+      "redirected_period": ".",
+      "duplicated": "This page was duplicated from",
+      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
       "stale": "More than {{count}} year has passed since last update.",
@@ -323,6 +325,16 @@
     "outdated": "Page is updated someone and now outdated.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete completely"
     "user_not_admin": "Only admin user can delete completely"
   },
   },
+  "page_history": {
+    "revision_list": "Revision list",
+    "revision": "version",
+    "comparing_source": "Source",
+    "comparing_target": "Target",
+    "comparing_revisions": "Comparing versions",
+    "compare_latest":"Compare latest revision",
+    "compare_previous":"Compare previous revision",
+    "comparing_with_latest": "Always compare with the latest version"
+  },
   "modal_rename": {
   "modal_rename": {
     "label": {
     "label": {
       "Move/Rename page": "Move/Rename page",
       "Move/Rename page": "Move/Rename page",

+ 16 - 4
resource/locales/ja_JP/translation.json

@@ -77,7 +77,6 @@
   "Go to this version": "このバージョンを見る",
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "View diff": "差分を表示",
   "No diff": "差分なし",
   "No diff": "差分なし",
-  "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
   "User ID": "ユーザーID",
   "User ID": "ユーザーID",
   "User Information": "ユーザー情報",
   "User Information": "ユーザー情報",
   "Basic Info": "ユーザーの基本情報",
   "Basic Info": "ユーザーの基本情報",
@@ -295,9 +294,12 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "これは現在の版ではありません。",
       "version": "これは現在の版ではありません。",
-      "moved": "このページは <code>%s</code> から移動しました。",
-      "redirected": "リダイレクト元 >> <code>%s</code>",
-      "duplicated": "このページは <code>%s</code> から複製されました。",
+      "moved": "このページは",
+      "moved_period":"から移動しました。",
+      "redirected": "リダイレクト元 >>",
+      "redirected_period":"",
+      "duplicated": "このページは",
+      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -325,6 +327,16 @@
     "outdated": "ページが他のユーザーによって更新されました。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが完全削除できます"
     "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
   },
+  "page_history": {
+    "revision_list": "更新履歴",
+    "revision": "バージョン",
+    "comparing_source": "ソース",
+    "comparing_target": "ターゲット",
+    "comparing_revisions": "比較",
+    "compare_latest":"最新と比較",
+    "compare_previous":"1つ前のバージョンと比較",
+    "comparing_with_latest": "常に最新バージョンと比較する"
+  },
   "modal_rename": {
   "modal_rename": {
     "label": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "Move/Rename page": "ページを移動/名前変更する",

+ 17 - 5
resource/locales/zh_CN/translation.json

@@ -80,7 +80,6 @@
 	"Go to this version": "查看此版本",
 	"Go to this version": "查看此版本",
 	"View diff": "查看差异",
 	"View diff": "查看差异",
 	"No diff": "无差异",
 	"No diff": "无差异",
-	"Shrink versions that have no diffs": "收缩没有差异的版本",
 	"User ID": "用户ID",
 	"User ID": "用户ID",
 	"Home": "首页",
 	"Home": "首页",
 	"My Drafts": "My Drafts",
 	"My Drafts": "My Drafts",
@@ -274,9 +273,12 @@
 	"page_page": {
 	"page_page": {
 		"notice": {
 		"notice": {
 			"version": "这不是当前版本。",
 			"version": "这不是当前版本。",
-			"moved": "此页已从<code>%s</code>",
-			"redirected": "您将从<code>%s</code>",
-			"duplicated": "此页来自<code>%s</code>",
+			"moved": "此页已从",
+      "moved_period": "",
+			"redirected": "您将从",
+      "redirected_period": "",
+			"duplicated": "此页来自",
+      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -303,7 +305,17 @@
 		"already_exists": "新建页面已存在",
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以完全删除"
 		"user_not_admin": "仅管理员用户可以完全删除"
-	},
+  },
+  "page_history": {
+    "revision_list": "修订清单",
+    "revision": "版本",
+    "comparing_source": "源头",
+    "comparing_target": "目标",
+    "comparing_revisions": "比较版本",
+    "compare_latest":"比較最新版本",
+    "compare_previous":"比較以前的版本",
+    "comparing_with_latest": "一定要与最新版本进行比较"
+  },
 	"modal_rename": {
 	"modal_rename": {
 		"label": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
 			"Move/Rename page": "页面 移动/重命名",

+ 17 - 1
src/client/js/app.jsx

@@ -17,6 +17,9 @@ import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
 import PageManagement from './components/Page/PageManagement';
 import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import ShareLinkAlert from './components/Page/ShareLinkAlert';
+import DuplicatedAlert from './components/Page/DuplicatedAlert';
+import RedirectedAlert from './components/Page/RedirectedAlert';
+import RenamedAlert from './components/Page/RenamedAlert';
 import TrashPageList from './components/TrashPageList';
 import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import NotFoundPage from './components/NotFoundPage';
 import NotFoundPage from './components/NotFoundPage';
@@ -37,10 +40,12 @@ import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSw
 import NavigationContainer from './services/NavigationContainer';
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import PageContainer from './services/PageContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
+import RevisionComparerContainer from './services/RevisionComparerContainer';
 import CommentContainer from './services/CommentContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import TagContainer from './services/TagContainer';
 import PersonalContainer from './services/PersonalContainer';
 import PersonalContainer from './services/PersonalContainer';
+import PageAccessoriesContainer from './services/PageAccessoriesContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
@@ -55,12 +60,15 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const navigationContainer = new NavigationContainer(appContainer);
 const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
+const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
+const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -100,6 +108,9 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
   'grw-fab-container': <Fab />,
 
 
   'share-link-alert': <ShareLinkAlert />,
   'share-link-alert': <ShareLinkAlert />,
+  'duplicated-alert': <DuplicatedAlert />,
+  'redirected-alert': <RedirectedAlert />,
+  'renamed-alert': <RenamedAlert />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists
@@ -114,6 +125,11 @@ if (pageContainer.state.pageId != null) {
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
   });
   });
+
+  // show the Page accessory modal when query of "compare" is requested
+  if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
+    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+  }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {

+ 24 - 0
src/client/js/components/Page/DuplicatedAlert.jsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const DuplicatedAlert = (props) => {
+  const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('duplicated');
+
+  return (
+    <div className="alert alert-success py-3 px-4">
+      <strong>
+        { t('Duplicated') }: {t('page_page.notice.duplicated')} <code>{fromPath}</code> {t('page_page.notice.duplicated_period')}
+      </strong>
+    </div>
+  );
+};
+
+DuplicatedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(DuplicatedAlert);

+ 22 - 0
src/client/js/components/Page/RedirectedAlert.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const RedirectedAlert = (props) => {
+  const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('redirectFrom');
+
+  return (
+    <>
+      <strong>{ t('Redirected') }:</strong> { t('page_page.notice.redirected')} <code>{fromPath}</code> {t('page_page.notice.redirected_period')}
+    </>
+  );
+};
+
+RedirectedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(RedirectedAlert);

+ 22 - 0
src/client/js/components/Page/RenamedAlert.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+
+const RenamedAlert = (props) => {
+  const { t } = props;
+  const urlParams = new URLSearchParams(window.location.search);
+  const fromPath = urlParams.get('renamedFrom');
+
+  return (
+    <>
+      <strong>{ t('Moved') }:</strong> {t('page_page.notice.moved')} <code>{fromPath}</code> {t('page_page.notice.moved_period')}
+    </>
+  );
+};
+
+RenamedAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(RenamedAlert);

+ 2 - 2
src/client/js/components/PageAccessoriesModal.jsx

@@ -109,10 +109,10 @@ const PageAccessoriesModal = (props) => {
             hideBorderBottom
             hideBorderBottom
           />
           />
         </ModalHeader>
         </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style p-0">
+        <ModalBody className="overflow-auto grw-modal-body-style">
           {/* Do not use CustomTabContent because of performance problem:
           {/* Do not use CustomTabContent because of performance problem:
               the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
               the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab} className="p-5">
+          <TabContent activeTab={activeTab}>
             <TabPane tabId="pagelist">
             <TabPane tabId="pagelist">
               {activeComponents.has('pagelist') && <PageList />}
               {activeComponents.has('pagelist') && <PageList />}
             </TabPane>
             </TabPane>

+ 20 - 10
src/client/js/components/PageHistory.jsx

@@ -2,21 +2,23 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 import loggerFactory from '@alias/logger';
 
 
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
 import { withLoadingSppiner } from './SuspenseUtils';
 import { withLoadingSppiner } from './SuspenseUtils';
-import PageRevisionList from './PageHistory/PageRevisionList';
+import PageRevisionTable from './PageHistory/PageRevisionTable';
 
 
 import PageHistroyContainer from '../services/PageHistoryContainer';
 import PageHistroyContainer from '../services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
-
+import RevisionComparer from './RevisionComparer/RevisionComparer';
+import RevisionComparerContainer from '../services/RevisionComparerContainer';
 
 
 const logger = loggerFactory('growi:PageHistory');
 const logger = loggerFactory('growi:PageHistory');
 
 
 function PageHistory(props) {
 function PageHistory(props) {
-  const { pageHistoryContainer } = props;
-  const { getPreviousRevision, onDiffOpenClicked } = pageHistoryContainer;
+  const { pageHistoryContainer, revisionComparerContainer, t } = props;
+  const { getPreviousRevision } = pageHistoryContainer;
   const {
   const {
     activePage, totalPages, pagingLimit, revisions, diffOpened,
     activePage, totalPages, pagingLimit, revisions, diffOpened,
   } = pageHistoryContainer.state;
   } = pageHistoryContainer.state;
@@ -44,6 +46,7 @@ function PageHistory(props) {
     throw new Promise(async() => {
     throw new Promise(async() => {
       try {
       try {
         await props.pageHistoryContainer.retrieveRevisions(1);
         await props.pageHistoryContainer.retrieveRevisions(1);
+        await props.revisionComparerContainer.initRevisions();
       }
       }
       catch (err) {
       catch (err) {
         toastError(err);
         toastError(err);
@@ -66,24 +69,31 @@ function PageHistory(props) {
   }
   }
 
 
   return (
   return (
-    <div>
-      <PageRevisionList
+    <div className="revision-history">
+      <h3 className="pb-3">{t('page_history.revision_list')}</h3>
+      <PageRevisionTable
         pageHistoryContainer={pageHistoryContainer}
         pageHistoryContainer={pageHistoryContainer}
+        revisionComparerContainer={revisionComparerContainer}
         revisions={revisions}
         revisions={revisions}
         diffOpened={diffOpened}
         diffOpened={diffOpened}
         getPreviousRevision={getPreviousRevision}
         getPreviousRevision={getPreviousRevision}
-        onDiffOpenClicked={onDiffOpenClicked}
       />
       />
-      {pager()}
+      <div className="my-3">
+        {pager()}
+      </div>
+      <RevisionComparer />
     </div>
     </div>
   );
   );
 
 
 }
 }
 
 
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer]);
+const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
 
 
 PageHistory.propTypes = {
 PageHistory.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 };
 
 
-export default RenderPageHistoryWrapper;
+export default withTranslation()(RenderPageHistoryWrapper);

+ 0 - 133
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -1,133 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import PageHistroyContainer from '../../services/PageHistoryContainer';
-
-import Revision from './Revision';
-import RevisionDiff from './RevisionDiff';
-
-class PageRevisionList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isCompactNodiffRevisions: true,
-    };
-
-    this.cbCompactizeChangeHandler = this.cbCompactizeChangeHandler.bind(this);
-  }
-
-  cbCompactizeChangeHandler() {
-    this.setState({ isCompactNodiffRevisions: !this.state.isCompactNodiffRevisions });
-  }
-
-  /**
-   * render a row (Revision component and RevisionDiff component)
-   * @param {Revison} revision
-   * @param {Revision} previousRevision
-   * @param {boolean} hasDiff whether revision has difference to previousRevision
-   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
-   */
-  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
-    const revisionId = revision._id;
-    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-
-    const classNames = ['revision-history-outer'];
-    if (isContiguousNodiff) {
-      classNames.push('revision-history-outer-contiguous-nodiff');
-    }
-
-    return (
-      <div className={classNames.join(' ')} key={`revision-history-${revisionId}`}>
-        <Revision
-          t={this.props.t}
-          revision={revision}
-          revisionDiffOpened={revisionDiffOpened}
-          hasDiff={hasDiff}
-          isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
-          onDiffOpenClicked={this.props.onDiffOpenClicked}
-          key={`revision-history-rev-${revisionId}`}
-        />
-        { hasDiff
-          && (
-          <RevisionDiff
-            revisionDiffOpened={revisionDiffOpened}
-            currentRevision={revision}
-            previousRevision={previousRevision}
-            key={`revision-deff-${revisionId}`}
-          />
-          )
-        }
-      </div>
-    );
-  }
-
-  render() {
-    const { t, pageHistoryContainer } = this.props;
-
-    const revisions = this.props.revisions;
-    const revisionCount = this.props.revisions.length;
-
-    let hasDiffPrev;
-
-    const revisionList = this.props.revisions.map((revision, idx) => {
-      // Returns null because the last revision is for the bottom diff display
-      if (idx === pageHistoryContainer.state.pagingLimit) {
-        return null;
-      }
-
-      let previousRevision;
-      if (idx + 1 < revisionCount) {
-        previousRevision = revisions[idx + 1];
-      }
-      else {
-        previousRevision = revision; // if it is the first revision, show full text as diff text
-      }
-
-      const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
-      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
-
-      hasDiffPrev = hasDiff;
-
-      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
-    });
-
-    const classNames = ['revision-history-list'];
-    if (this.state.isCompactNodiffRevisions) {
-      classNames.push('revision-history-list-compact');
-    }
-
-    return (
-      <React.Fragment>
-        <div className="custom-control custom-checkbox custom-checkbox-info float-right">
-          <input
-            type="checkbox"
-            id="cbCompactize"
-            className="custom-control-input"
-            checked={this.state.isCompactNodiffRevisions}
-            onChange={this.cbCompactizeChangeHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbCompactize">{ t('Shrink versions that have no diffs') }</label>
-        </div>
-        <div className="clearfix"></div>
-        <div className={classNames.join(' ')}>
-          {revisionList}
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-PageRevisionList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-
-  revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
-  onDiffOpenClicked: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(PageRevisionList);

+ 155 - 0
src/client/js/components/PageHistory/PageRevisionTable.jsx

@@ -0,0 +1,155 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import PageHistroyContainer from '../../services/PageHistoryContainer';
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+import Revision from './Revision';
+
+class PageRevisionTable extends React.Component {
+
+  /**
+   * render a row (Revision component and RevisionDiff component)
+   * @param {Revison} revision
+   * @param {Revision} previousRevision
+   * @param {boolean} hasDiff whether revision has difference to previousRevision
+   * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
+   */
+  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const { revisionComparerContainer, t } = this.props;
+    const { latestRevision } = this.props.pageHistoryContainer.state;
+    const revisionId = revision._id;
+    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
+    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+    const handleCompareLatestRevisionButton = () => {
+      revisionComparerContainer.setState({ sourceRevision: revision });
+      revisionComparerContainer.setState({ targetRevision: latestRevision });
+    };
+
+    const handleComparePreviousRevisionButton = () => {
+      revisionComparerContainer.setState({ sourceRevision: previousRevision });
+      revisionComparerContainer.setState({ targetRevision: revision });
+    };
+
+    return (
+      <tr className="d-flex" key={`revision-history-${revisionId}`}>
+        <td className="col" key={`revision-history-top-${revisionId}`}>
+          <div className="d-lg-flex">
+            <Revision
+              t={this.props.t}
+              revision={revision}
+              isLatestRevision={revision === latestRevision}
+              revisionDiffOpened={revisionDiffOpened}
+              hasDiff={hasDiff}
+              key={`revision-history-rev-${revisionId}`}
+            />
+            {hasDiff && (
+              <div className="ml-md-3 mt-auto">
+                <div className="btn-group">
+                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleCompareLatestRevisionButton}>
+                    {t('page_history.compare_latest')}
+                  </button>
+                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleComparePreviousRevisionButton}>
+                    {t('page_history.compare_previous')}
+                  </button>
+                </div>
+              </div>
+           )}
+          </div>
+        </td>
+        <td className="col-1">
+          {(hasDiff || revision._id === sourceRevision?._id) && (
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareSource-${revision._id}`}
+                name="compareSource"
+                value={revision._id}
+                checked={revision._id === sourceRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+              />
+              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+            </div>
+          )}
+        </td>
+        <td className="col-2">
+          {(hasDiff || revision._id === targetRevision?._id) && (
+            <div className="custom-control custom-radio custom-control-inline mr-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id={`compareTarget-${revision._id}`}
+                name="compareTarget"
+                value={revision._id}
+                checked={revision._id === targetRevision?._id}
+                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+              />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+            </div>
+          )}
+        </td>
+      </tr>
+    );
+  }
+
+  render() {
+    const { t, pageHistoryContainer } = this.props;
+
+    const revisions = this.props.revisions;
+    const revisionCount = this.props.revisions.length;
+
+    let hasDiffPrev;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      // Returns null because the last revision is for the bottom diff display
+      if (idx === pageHistoryContainer.state.pagingLimit) {
+        return null;
+      }
+
+      let previousRevision;
+      if (idx + 1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      }
+      else {
+        previousRevision = revision; // if it is the first revision, show full text as diff text
+      }
+
+      const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
+      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
+
+      hasDiffPrev = hasDiff;
+
+      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+    });
+
+    return (
+      <table className="table revision-history-table">
+        <thead>
+          <tr className="d-flex">
+            <th className="col">{ t('page_history.revision') }</th>
+            <th className="col-1">{ t('page_history.comparing_source') }</th>
+            <th className="col-2">{ t('page_history.comparing_target') }</th>
+          </tr>
+        </thead>
+        <tbody className="overflow-auto d-block">
+          {revisionList}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+PageRevisionTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revisions: PropTypes.array,
+  diffOpened: PropTypes.object,
+};
+
+export default withTranslation()(PageRevisionTable);

+ 15 - 45
src/client/js/components/PageHistory/Revision.jsx

@@ -7,20 +7,9 @@ import UserPicture from '../User/UserPicture';
 
 
 export default class Revision extends React.Component {
 export default class Revision extends React.Component {
 
 
-  constructor(props) {
-    super(props);
-
-    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
-  }
-
   componentDidMount() {
   componentDidMount() {
   }
   }
 
 
-  _onDiffOpenClicked(e) {
-    e.preventDefault();
-    this.props.onDiffOpenClicked(this.props.revision);
-  }
-
   renderSimplifiedNodiff(revision) {
   renderSimplifiedNodiff(revision) {
     const { t } = this.props;
     const { t } = this.props;
 
 
@@ -37,11 +26,9 @@ export default class Revision extends React.Component {
           {pic}
           {pic}
         </div>
         </div>
         <div className="ml-3">
         <div className="ml-3">
-          <div className="revision-history-meta">
-            <span className="text-muted small">
-              <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
-            </span>
-          </div>
+          <span className="text-muted small">
+            <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+          </span>
         </div>
         </div>
       </div>
       </div>
     );
     );
@@ -57,38 +44,22 @@ export default class Revision extends React.Component {
       pic = <UserPicture user={author} size="lg" />;
       pic = <UserPicture user={author} size="lg" />;
     }
     }
 
 
-    const iconClass = this.props.revisionDiffOpened ? 'fa fa-caret-down caret caret-opened' : 'fa fa-caret-down caret';
     return (
     return (
-      <div className="revision-history-main d-flex mt-3">
-        <div className="mt-2">
+      <div className="revision-history-main d-flex">
+        <div className="picture-container">
           {pic}
           {pic}
         </div>
         </div>
         <div className="ml-2">
         <div className="ml-2">
-          <div className="revision-history-author">
+          <div className="revision-history-author mb-1">
             <strong><Username user={author}></Username></strong>
             <strong><Username user={author}></Username></strong>
+            {this.props.isLatestRevision && <span className="badge badge-info ml-2">Latest</span>}
           </div>
           </div>
-          <div className="revision-history-meta">
-            <p>
-              <UserDate dateTime={revision.createdAt} />
-            </p>
-            <p>
-              <span className="d-inline-block" style={{ minWidth: '90px' }}>
-                { !this.props.hasDiff
-                  && <span className="text-muted">{ t('No diff') }</span>
-                }
-                { this.props.hasDiff
-                  && (
-                  // use dummy href attr (with preventDefault()), because don't apply style by a:not([href])
-                  <a className="diff-view" href="" onClick={this._onDiffOpenClicked}>
-                    <i className={iconClass}></i> {t('View diff')}
-                  </a>
-                  )
-                }
-              </span>
-              <a href={`?revision=${revision._id}`} className="ml-2">
-                <i className="icon-login"></i> { t('Go to this version') }
-              </a>
-            </p>
+          <div className="mb-1">
+            <UserDate dateTime={revision.createdAt} />
+            <br className="d-xl-none d-block" />
+            <a className="ml-xl-3" href={`?revision=${revision._id}`}>
+              <i className="icon-login"></i> { t('Go to this version') }
+            </a>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -98,7 +69,7 @@ export default class Revision extends React.Component {
   render() {
   render() {
     const revision = this.props.revision;
     const revision = this.props.revision;
 
 
-    if (this.props.isCompactNodiffRevisions && !this.props.hasDiff) {
+    if (!this.props.hasDiff) {
       return this.renderSimplifiedNodiff(revision);
       return this.renderSimplifiedNodiff(revision);
     }
     }
 
 
@@ -111,8 +82,7 @@ export default class Revision extends React.Component {
 Revision.propTypes = {
 Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
   revision: PropTypes.object,
+  isLatestRevision: PropTypes.bool.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
-  isCompactNodiffRevisions: PropTypes.bool.isRequired,
-  onDiffOpenClicked: PropTypes.func.isRequired,
 };
 };

+ 122 - 0
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -0,0 +1,122 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+import RevisionDiff from '../PageHistory/RevisionDiff';
+
+/* eslint-disable react/prop-types */
+const DropdownItemContents = ({ title, contents }) => (
+  <>
+    <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
+    <div className="card well mb-1 p-2">{contents}</div>
+  </>
+);
+/* eslint-enable react/prop-types */
+
+function encodeSpaces(str) {
+  if (str == null) {
+    return null;
+  }
+
+  // Encode SPACE and IDEOGRAPHIC SPACE
+  return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+}
+
+const RevisionComparer = (props) => {
+
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
+  const { t, revisionComparerContainer } = props;
+
+  function toggleDropdown() {
+    setDropdownOpen(!dropdownOpen);
+  }
+
+  const pagePathUrl = () => {
+    const { origin } = window.location;
+    const { path } = revisionComparerContainer.pageContainer.state;
+    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+    const urlParams = (sourceRevision && targetRevision ? `?compare=${sourceRevision._id}...${targetRevision._id}` : '');
+    return encodeSpaces(decodeURI(`${origin}/${path}${urlParams}`));
+  };
+
+  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+  const showDiff = (sourceRevision && targetRevision);
+
+  return (
+    <div className="revision-compare">
+      <div className="d-flex">
+        <h3 className="align-self-center mb-0">{ t('page_history.comparing_revisions') }</h3>
+        <div className="align-self-center ml-3">
+          <div className="custom-control custom-switch">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              id="comparingWithLatest"
+              onChange={() => revisionComparerContainer.toggleCompareWithLatest()}
+            />
+            <label className="custom-control-label" htmlFor="comparingWithLatest">
+              { t('page_history.comparing_with_latest') }
+            </label>
+          </div>
+        </div>
+        <Dropdown
+          className="grw-copy-dropdown align-self-center ml-auto"
+          isOpen={dropdownOpen}
+          toggle={() => toggleDropdown()}
+        >
+          <DropdownToggle
+            caret
+            className="d-block text-muted bg-transparent btn-copy border-0 py-0"
+          >
+            <i className="ti-clipboard"></i>
+          </DropdownToggle>
+          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+            {/* Page path URL */}
+            <CopyToClipboard text={pagePathUrl()}>
+              <DropdownItem className="px-3">
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl()} />
+              </DropdownItem>
+            </CopyToClipboard>
+            <DropdownItem divider className="my-0"></DropdownItem>
+          </DropdownMenu>
+        </Dropdown>
+      </div>
+
+      <hr />
+
+      <div className="revision-compare-outer">
+        { showDiff && (
+          <RevisionDiff
+            revisionDiffOpened
+            previousRevision={sourceRevision}
+            currentRevision={targetRevision}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
+
+RevisionComparer.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revisions: PropTypes.array,
+};
+
+export default withTranslation()(RevisionComparerWrapper);

+ 3 - 0
src/client/js/components/StaffCredit/Contributor.js

@@ -103,6 +103,9 @@ const contributors = [
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
           { position: 'Flatt Security', name: 'stypr' },
           { position: 'Flatt Security', name: 'stypr' },
+          { position: 'Flatt Security', name: 'Azara/Norihide Saito' },
+          { position: 'CyberAgent, Inc.', name: 'Daisuke Takahashi' },
+          { position: 'Mitsui Bussan Secure Directions, Inc.', name: 'Yuji Tounai' },
         ],
         ],
       },
       },
     ],
     ],

+ 3 - 0
src/client/js/services/EditorContainer.js

@@ -151,7 +151,10 @@ export default class EditorContainer extends Container {
     return opt;
     return opt;
   }
   }
 
 
+  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
   showUnsavedWarning(e) {
   showUnsavedWarning(e) {
+    // Cancel the event
+    e.preventDefault();
     // display browser default message
     // display browser default message
     e.returnValue = '';
     e.returnValue = '';
     return '';
     return '';

+ 5 - 12
src/client/js/services/PageHistoryContainer.js

@@ -24,6 +24,7 @@ export default class PageHistoryContainer extends Container {
 
 
       // set dummy rivisions for using suspense
       // set dummy rivisions for using suspense
       revisions: this.dummyRevisions,
       revisions: this.dummyRevisions,
+      latestRevision: this.dummyRevisions,
       diffOpened: {},
       diffOpened: {},
 
 
       totalPages: 0,
       totalPages: 0,
@@ -32,7 +33,6 @@ export default class PageHistoryContainer extends Container {
     };
     };
 
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);
     this.retrieveRevisions = this.retrieveRevisions.bind(this);
-    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
     this.getPreviousRevision = this.getPreviousRevision.bind(this);
     this.getPreviousRevision = this.getPreviousRevision.bind(this);
     this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
     this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
   }
   }
@@ -96,6 +96,10 @@ export default class PageHistoryContainer extends Container {
     this.setState({ revisions: rev });
     this.setState({ revisions: rev });
     this.setState({ diffOpened });
     this.setState({ diffOpened });
 
 
+    if (selectedPage === 1) {
+      this.setState({ latestRevision: rev[0] });
+    }
+
     // load 0, and last default
     // load 0, and last default
     if (rev[0]) {
     if (rev[0]) {
       this.fetchPageRevisionBody(rev[0]);
       this.fetchPageRevisionBody(rev[0]);
@@ -110,17 +114,6 @@ export default class PageHistoryContainer extends Container {
     return;
     return;
   }
   }
 
 
-  onDiffOpenClicked(revision) {
-    const { diffOpened } = this.state;
-    const revisionId = revision._id;
-
-    diffOpened[revisionId] = !(diffOpened[revisionId]);
-    this.setState(diffOpened);
-
-    this.fetchPageRevisionBody(revision);
-    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
-  }
-
   getPreviousRevision(currentRevision) {
   getPreviousRevision(currentRevision) {
     let cursor = null;
     let cursor = null;
     for (const revision of this.state.revisions) {
     for (const revision of this.state.revisions) {

+ 128 - 0
src/client/js/services/RevisionComparerContainer.js

@@ -0,0 +1,128 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:PageHistoryContainer');
+
+/**
+ * Service container for personal settings page (RevisionCompare.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class RevisionComparerContainer extends Container {
+
+  constructor(appContainer, pageContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.pageContainer = pageContainer;
+
+    this.state = {
+      errMessage: null,
+
+      sourceRevision: null,
+      targetRevision: null,
+      latestRevision: null,
+    };
+
+    this.initRevisions = this.initRevisions.bind(this);
+    this.toggleCompareWithLatest = this.toggleCompareWithLatest.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'RevisionComparerContainer';
+  }
+
+  /**
+   * Initialize the revisions
+   */
+  async initRevisions() {
+    const latestRevision = await this.fetchLatestRevision();
+
+    const [sourceRevisionId, targetRevisionId] = this.getRevisionIDsToCompareAsParam();
+    const sourceRevision = sourceRevisionId ? await this.fetchRevision(sourceRevisionId) : latestRevision;
+    const targetRevision = targetRevisionId ? await this.fetchRevision(targetRevisionId) : latestRevision;
+    const compareWithLatest = targetRevisionId ? false : this.state.compareWithLatest;
+
+    this.setState({
+      sourceRevision, targetRevision, latestRevision, compareWithLatest,
+    });
+  }
+
+  /**
+   * Get the IDs of the comparison source and target from "window.location" as an array
+   */
+  getRevisionIDsToCompareAsParam() {
+    const searchParams = {};
+    for (const param of window.location.search?.substr(1)?.split('&')) {
+      const [k, v] = param.split('=');
+      searchParams[k] = v;
+    }
+    if (!searchParams.compare) {
+      return [];
+    }
+
+    return searchParams.compare.split('...') || [];
+  }
+
+  /**
+   * Fetch the latest revision
+   */
+  async fetchLatestRevision() {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    try {
+      const res = await this.appContainer.apiv3Get('/revisions/list', {
+        pageId, shareLinkId, page: 1, limit: 1,
+      });
+      return res.data.docs[0];
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+    return null;
+  }
+
+  /**
+   * Fetch the revision of the specified ID
+   * @param {string} revision ID
+   */
+  async fetchRevision(revisionId) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    try {
+      const res = await this.appContainer.apiv3Get(`/revisions/${revisionId}`, {
+        pageId, shareLinkId,
+      });
+      return res.data.revision;
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+    return null;
+  }
+
+  /**
+   * toggle state "compareWithLatest", and if true, set "targetRevision" to the latest revision
+   */
+  toggleCompareWithLatest() {
+    const { compareWithLatest } = this.state;
+    const newCompareWithLatest = !compareWithLatest;
+
+    this.setState(
+      Object.assign(
+        { compareWithLatest: newCompareWithLatest },
+        (newCompareWithLatest === true ? { targetRevision: this.state.latestRevision } : {}),
+      ),
+    );
+  }
+
+}

+ 5 - 2
src/client/styles/scss/_on-edit.scss

@@ -232,12 +232,15 @@ body.on-edit {
     }
     }
 
 
     .page-editor-preview-container {
     .page-editor-preview-container {
-      overflow-y: scroll;
     }
     }
 
 
     .page-editor-preview-body {
     .page-editor-preview-body {
-      max-width: 980px;
       padding: 18px 15px 0;
       padding: 18px 15px 0;
+      overflow-y: scroll;
+    }
+
+    .wiki {
+      max-width: 980px;
       margin: 0 auto;
       margin: 0 auto;
     }
     }
 
 

+ 4 - 0
src/client/styles/scss/_page-accessories-modal.scss

@@ -5,6 +5,10 @@
     }
     }
   }
   }
 
 
+  .modal-body {
+    padding: 25px 30px;
+  }
+
   .grw-modal-body-style {
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
     max-height: calc(100vh - 100px);
   }
   }

+ 33 - 0
src/client/styles/scss/_page-history.scss

@@ -0,0 +1,33 @@
+.revision-history-table {
+  tbody {
+    max-height: 250px;
+  }
+}
+
+.revision-history-main {
+  img.picture-lg {
+    width: 32px;
+    height: 32px;
+  }
+}
+
+.revision-history-main-nodiff {
+  .picture-container {
+    min-width: 32px;
+    text-align: center; // centering .picture
+  }
+}
+
+.revision-history-diff {
+  padding-left: 40px;
+  color: $gray-900;
+  table-layout: fixed;
+}
+
+.revision-compare {
+  .revision-compare-outer {
+    min-height: 100px;
+    max-height: 250px;
+    overflow: auto;
+  }
+}

+ 0 - 68
src/client/styles/scss/_page.scss

@@ -1,74 +1,6 @@
 // import diff2html styles
 // import diff2html styles
 @import '~diff2html/bundles/css/diff2html.min.css';
 @import '~diff2html/bundles/css/diff2html.min.css';
 
 
-.revision-history {
-  .revision-history-list {
-    .revision-history-outer {
-      // add border-top except of first element
-      &:not(:first-of-type) {
-        @extend .border-top;
-      }
-
-      .revision-history-main {
-        .picture-lg {
-          width: 32px;
-          height: 32px;
-        }
-
-        .revision-history-meta {
-          a:hover {
-            cursor: pointer;
-          }
-        }
-
-        .caret {
-          transition: 0.4s;
-          transform: rotate(-90deg);
-
-          &.caret-opened {
-            transform: rotate(0deg);
-          }
-        }
-      }
-
-      .revision-history-main-nodiff {
-        .picture-container {
-          min-width: 32px;
-          text-align: center; // centering .picture
-        }
-      }
-
-      .revision-history-diff {
-        padding-left: 40px;
-        color: $gray-900;
-        table-layout: fixed;
-      }
-    }
-
-    li {
-      position: relative;
-      list-style: none;
-    }
-  }
-
-  // compacted list
-  .revision-history-list-compact {
-    .revision-history-outer-contiguous-nodiff {
-      border-top: unset !important; // force unset border
-    }
-  }
-
-  // adjust
-  // this is for diff2html. hide page name from diff view
-  .d2h-file-header {
-    display: none;
-  }
-
-  .d2h-diff-tbody {
-    background-color: white;
-  }
-}
-
 /**
 /**
  * for table with handsontable modal button
  * for table with handsontable modal button
  */
  */

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -55,6 +55,7 @@
 @import 'page-path';
 @import 'page-path';
 @import 'page';
 @import 'page';
 @import 'page-presentation';
 @import 'page-presentation';
+@import 'page-history';
 @import 'search';
 @import 'search';
 @import 'shortcuts';
 @import 'shortcuts';
 @import 'sidebar';
 @import 'sidebar';

+ 4 - 2
src/server/routes/apiv3/app-settings.js

@@ -6,6 +6,7 @@ const debug = require('debug')('growi:routes:admin');
 
 
 const express = require('express');
 const express = require('express');
 
 
+const { pathUtils } = require('growi-commons');
 const { listLocaleIds } = require('@commons/util/locale-utils');
 const { listLocaleIds } = require('@commons/util/locale-utils');
 
 
 const router = express.Router();
 const router = express.Router();
@@ -156,7 +157,8 @@ module.exports = (crowi) => {
       body('fileUpload').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     ],
     siteUrlSetting: [
     siteUrlSetting: [
-      body('siteUrl').trim().matches(/^(https?:\/\/[^/]+|)$/).isURL({ require_tld: false }),
+      // https://regex101.com/r/5Xef8V/1
+      body('siteUrl').trim().matches(/^(https?:\/\/)/).isURL({ require_tld: false }),
     ],
     ],
     mailSetting: [
     mailSetting: [
       body('fromAddress').trim().if(value => value !== '').isEmail(),
       body('fromAddress').trim().if(value => value !== '').isEmail(),
@@ -334,7 +336,7 @@ module.exports = (crowi) => {
   router.put('/site-url-setting', loginRequiredStrictly, adminRequired, csrf, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
   router.put('/site-url-setting', loginRequiredStrictly, adminRequired, csrf, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
 
 
     const requestSiteUrlSettingParams = {
     const requestSiteUrlSettingParams = {
-      'app:siteUrl': req.body.siteUrl,
+      'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
     };
     };
 
 
     try {
     try {

+ 11 - 1
src/server/routes/apiv3/revisions.js

@@ -91,7 +91,17 @@ module.exports = (crowi) => {
    *            name: pageId
    *            name: pageId
    *            schema:
    *            schema:
    *              type: string
    *              type: string
-   *              description:  page id
+   *              description: page id
+   *          - in: query
+   *            name: page
+   *            description: selected page number
+   *            schema:
+   *              type: number
+   *          - in: query
+   *            name: limit
+   *            description: page item limit
+   *            schema:
+   *              type: number
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Return revisions belong to page
    *            description: Return revisions belong to page

+ 5 - 5
src/server/service/page.js

@@ -151,7 +151,7 @@ class PageService {
       .addConditionToFilteringByViewer(user)
       .addConditionToFilteringByViewer(user)
       .query
       .query
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     const renameDescendants = this.renameDescendants.bind(this);
     const renameDescendants = this.renameDescendants.bind(this);
     const pageEvent = this.pageEvent;
     const pageEvent = this.pageEvent;
@@ -353,7 +353,7 @@ class PageService {
       .addConditionToFilteringByViewer(user)
       .addConditionToFilteringByViewer(user)
       .query
       .query
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const pageEvent = this.pageEvent;
     const pageEvent = this.pageEvent;
@@ -488,7 +488,7 @@ class PageService {
       .addConditionToFilteringByViewer(user)
       .addConditionToFilteringByViewer(user)
       .query
       .query
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     const deleteDescendants = this.deleteDescendants.bind(this);
     const deleteDescendants = this.deleteDescendants.bind(this);
     let count = 0;
     let count = 0;
@@ -564,7 +564,7 @@ class PageService {
       .addConditionToFilteringByViewer(user)
       .addConditionToFilteringByViewer(user)
       .query
       .query
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     let count = 0;
     let count = 0;
@@ -690,7 +690,7 @@ class PageService {
       .addConditionToFilteringByViewer(user)
       .addConditionToFilteringByViewer(user)
       .query
       .query
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     let count = 0;
     let count = 0;

+ 1 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -390,7 +390,7 @@ class ElasticsearchDelegator {
         { path: 'revision', model: 'Revision', select: 'body' },
         { path: 'revision', model: 'Revision', select: 'body' },
       ])
       ])
       .lean()
       .lean()
-      .cursor();
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
     let skipped = 0;
     let skipped = 0;
     const thinOutStream = new Transform({
     const thinOutStream = new Transform({

+ 3 - 7
src/server/views/widget/page_alerts.html

@@ -33,10 +33,10 @@
       <span>
       <span>
         {% set fromPath = req.query.renamedFrom or req.query.redirectFrom %}
         {% set fromPath = req.query.renamedFrom or req.query.redirectFrom %}
         {% if redirectFrom or req.query.redirectFrom %}
         {% if redirectFrom or req.query.redirectFrom %}
-          <strong>{{ t('Redirected') }}:</strong> {{ t('page_page.notice.redirected', fromPath | preventXss) }}
+        <div id="redirected-alert"></div>
         {% endif %}
         {% endif %}
         {% if req.query.renamedFrom %}
         {% if req.query.renamedFrom %}
-          <strong>{{ t('Moved') }}:</strong> {{ t('page_page.notice.moved', fromPath | preventXss) }}
+        <div id="renamed-alert"></div>
         {% endif %}
         {% endif %}
       </span>
       </span>
       {% set hasRedirectLink = redirectFrom or req.query.redirectFrom or req.query.withRedirect %}
       {% set hasRedirectLink = redirectFrom or req.query.redirectFrom or req.query.withRedirect %}
@@ -50,11 +50,7 @@
     {% endif %}
     {% endif %}
 
 
     {% if req.query.duplicated and not page.isDeleted() %}
     {% if req.query.duplicated and not page.isDeleted() %}
-    <div class="alert alert-success py-3 px-4">
-      <span>
-        <strong>{{ t('Duplicated') }}: </strong> {{ t('page_page.notice.duplicated', req.query.duplicated | preventXss) }}
-      </span>
-    </div>
+    <div id="duplicated-alert"></div>
     {% endif %}
     {% endif %}
 
 
     {% if req.query.unlinked %}
     {% if req.query.unlinked %}