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

Merge pull request #3297 from weseek/feat/revision-compare

Feat/revision compare
Yuki Takei 5 лет назад
Родитель
Сommit
75c0630577

+ 8 - 0
resource/locales/en_US/translation.json

@@ -323,6 +323,14 @@
     "outdated": "Page is updated someone and now outdated.",
     "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",
+    "comparing_with_latest": "Always compare with the latest version"
+  },
   "modal_rename": {
     "label": {
       "Move/Rename page": "Move/Rename page",

+ 8 - 0
resource/locales/ja_JP/translation.json

@@ -325,6 +325,14 @@
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
+  "page_history": {
+    "revision_list": "更新履歴",
+    "revision": "バージョン",
+    "comparing_source": "ソース",
+    "comparing_target": "ターゲット",
+    "comparing_revisions": "比較",
+    "comparing_with_latest": "常に最新バージョンと比較する"
+  },
   "modal_rename": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",

+ 9 - 1
resource/locales/zh_CN/translation.json

@@ -303,7 +303,15 @@
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以完全删除"
-	},
+  },
+  "page_history": {
+    "revision_list": "修订清单",
+    "revision": "版本",
+    "comparing_source": "源头",
+    "comparing_target": "目标",
+    "comparing_revisions": "比较版本",
+    "comparing_with_latest": "一定要与最新版本进行比较"
+  },
 	"modal_rename": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",

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

@@ -40,10 +40,12 @@ import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSw
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
+import RevisionComparerContainer from './services/RevisionComparerContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import PersonalContainer from './services/PersonalContainer';
+import PageAccessoriesContainer from './services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
 
@@ -58,12 +60,15 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
+const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
+const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 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');
@@ -120,6 +125,11 @@ if (pageContainer.state.pageId != null) {
     'recent-created-icon': <RecentlyCreatedIcon />,
     '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) {
   Object.assign(componentMappings, {

+ 7 - 3
src/client/js/components/PageHistory.jsx

@@ -10,7 +10,8 @@ import PageRevisionList from './PageHistory/PageRevisionList';
 
 import PageHistroyContainer from '../services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
-
+import RevisionComparer from './RevisionComparer/RevisionComparer';
+import RevisionComparerContainer from '../services/RevisionComparerContainer';
 
 const logger = loggerFactory('growi:PageHistory');
 
@@ -44,6 +45,7 @@ function PageHistory(props) {
     throw new Promise(async() => {
       try {
         await props.pageHistoryContainer.retrieveRevisions(1);
+        await props.revisionComparerContainer.initRevisions();
       }
       catch (err) {
         toastError(err);
@@ -66,7 +68,7 @@ function PageHistory(props) {
   }
 
   return (
-    <div>
+    <div className="revision-history">
       <PageRevisionList
         pageHistoryContainer={pageHistoryContainer}
         revisions={revisions}
@@ -75,15 +77,17 @@ function PageHistory(props) {
         onDiffOpenClicked={onDiffOpenClicked}
       />
       {pager()}
+      <RevisionComparer />
     </div>
   );
 
 }
 
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer]);
+const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
 
 PageHistory.propTypes = {
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 
 export default RenderPageHistoryWrapper;

+ 47 - 21
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -6,6 +6,7 @@ import PageHistroyContainer from '../../services/PageHistoryContainer';
 
 import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
+import RevisionSelector from '../RevisionComparer/RevisionSelector';
 
 class PageRevisionList extends React.Component {
 
@@ -31,25 +32,36 @@ class PageRevisionList extends React.Component {
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    */
   renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
+    const { latestRevision } = this.props.pageHistoryContainer.state;
     const revisionId = revision._id;
     const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
 
-    const classNames = ['revision-history-outer'];
+    const classNames = ['revision-history-outer', 'row', 'no-gutters'];
     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}`}
-        />
+        <div className="col-8" key={`revision-history-top-${revisionId}`}>
+          <Revision
+            t={this.props.t}
+            revision={revision}
+            isLatestRevision={revision === latestRevision}
+            revisionDiffOpened={revisionDiffOpened}
+            hasDiff={hasDiff}
+            isCompactNodiffRevisions={this.state.isCompactNodiffRevisions}
+            onDiffOpenClicked={this.props.onDiffOpenClicked}
+            key={`revision-history-rev-${revisionId}`}
+          />
+        </div>
+        <div className="col-4 align-self-center">
+          <RevisionSelector
+            revision={revision}
+            hasDiff={hasDiff}
+            key={`revision-compare-target-selector-${revisionId}`}
+          />
+        </div>
         { hasDiff
           && (
           <RevisionDiff
@@ -101,19 +113,33 @@ class PageRevisionList extends React.Component {
 
     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 className="d-flex">
+          <h3>{t('page_history.revision_list')}</h3>
+          <div className="custom-control custom-checkbox custom-checkbox-info ml-auto">
+            <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>
-        <div className="clearfix"></div>
+        <hr />
         <div className={classNames.join(' ')}>
-          {revisionList}
+          <div className="revision-history-list-container">
+            <div className="revision-history-list-content-header sticky-top bg-white">
+              <div className="row no-gutters">
+                <div className="col-8">{ t('page_history.revision') }</div>
+                <div className="col-2 text-center">{ t('page_history.comparing_source') }</div>
+                <div className="col-2 text-center">{ t('page_history.comparing_target') }</div>
+              </div>
+            </div>
+            <div className="revision-history-list-content-body">
+              {revisionList}
+            </div>
+          </div>
         </div>
       </React.Fragment>
     );

+ 6 - 0
src/client/js/components/PageHistory/Revision.jsx

@@ -66,6 +66,11 @@ export default class Revision extends React.Component {
         <div className="ml-2">
           <div className="revision-history-author">
             <strong><Username user={author}></Username></strong>
+            { this.props.isLatestRevision
+              && (
+              <span className="badge badge-info ml-2">Latest</span>
+              )
+            }
           </div>
           <div className="revision-history-meta">
             <p>
@@ -111,6 +116,7 @@ export default class Revision extends React.Component {
 Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
+  isLatestRevision: PropTypes.bool.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   isCompactNodiffRevisions: PropTypes.bool.isRequired,

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

@@ -0,0 +1,123 @@
+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"
+              checked={revisionComparerContainer.state.compareWithLatest}
+              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);

+ 67 - 0
src/client/js/components/RevisionComparer/RevisionSelector.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import { withLoadingSppiner } from '../SuspenseUtils';
+
+import RevisionComparerContainer from '../../services/RevisionComparerContainer';
+
+const RevisionSelector = (props) => {
+
+  const { revision, hasDiff, revisionComparerContainer } = props;
+  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
+
+  if (!hasDiff) {
+    return <></>;
+  }
+
+  return (
+    <React.Fragment>
+      <div className="container-fluid px-0">
+        <div className="row no-gutters">
+          <div className="col text-center">
+            <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>
+          </div>
+          <div className="col text-center">
+            <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 })}
+                disabled={revisionComparerContainer.state.compareWithLatest}
+              />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+            </div>
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const RevisionSelectorWrapper = withUnstatedContainers(withLoadingSppiner(RevisionSelector), [RevisionComparerContainer]);
+
+RevisionSelector.propTypes = {
+  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
+
+  revision: PropTypes.object,
+  hasDiff: PropTypes.bool.isRequired,
+};
+
+export default RevisionSelectorWrapper;

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

@@ -24,6 +24,7 @@ export default class PageHistoryContainer extends Container {
 
       // set dummy rivisions for using suspense
       revisions: this.dummyRevisions,
+      latestRevision: this.dummyRevisions,
       diffOpened: {},
 
       totalPages: 0,
@@ -96,6 +97,10 @@ export default class PageHistoryContainer extends Container {
     this.setState({ revisions: rev });
     this.setState({ diffOpened });
 
+    if (selectedPage === 1) {
+      this.setState({ latestRevision: rev[0] });
+    }
+
     // load 0, and last default
     if (rev[0]) {
       this.fetchPageRevisionBody(rev[0]);

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

@@ -0,0 +1,129 @@
+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,
+      compareWithLatest: true,
+    };
+
+    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 } : {}),
+      ),
+    );
+  }
+
+}

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

@@ -0,0 +1,73 @@
+.revision-history {
+  .revision-history-list {
+    .revision-history-list-container {
+      min-height: 200px;
+      max-height: calc(100vh - 100px - 550px);
+      overflow: auto;
+    }
+
+    .revision-history-list-content-body {
+      .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
+    }
+  }
+
+  .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/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
  */

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

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

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

@@ -91,7 +91,17 @@ module.exports = (crowi) => {
    *            name: pageId
    *            schema:
    *              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:
    *          200:
    *            description: Return revisions belong to page