Browse Source

Merge pull request #5012 from weseek/feat/79579-upgrade-conflict-modal

feat: Resolve conflict with 3-way merge like editor
Yuki Takei 4 years ago
parent
commit
5989715086

+ 1 - 0
packages/app/package.json

@@ -86,6 +86,7 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",

+ 11 - 0
packages/app/resource/locales/en_US/translation.json

@@ -478,6 +478,17 @@
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
+    "resolve_conflict": "Resolve Conflict",
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "mine",
+    "origin_revision": "origin",
+    "latest_revision": "theirs",
+    "selected_editable_revision": "Selected Page Body (Editable)"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 11 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -478,6 +478,17 @@
     "enable_textlint": "Textlintを有効にする",
     "dont_ask_again": "常に許可する"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 11 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -456,6 +456,17 @@
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

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

@@ -41,6 +41,7 @@ export default class EditorContainer extends Container {
 
     this.initDrafts();
 
+    this.editorOptions = null;
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }

+ 27 - 1
packages/app/src/client/services/PageContainer.js

@@ -4,6 +4,8 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
+
+import { apiPost } from '../util/apiv1-client';
 import loggerFactory from '~/utils/logger';
 import { toastError } from '../util/apiNotification';
 
@@ -86,12 +88,15 @@ export default class PageContainer extends Container {
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
+      remoteRevisionBody: null,
+      remoteRevisionUpdateAt: null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictDiffModalOpen: false,
     };
 
     // parse creator, lastUpdateUser and revisionAuthor
@@ -103,6 +108,7 @@ export default class PageContainer extends Container {
     }
     try {
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+      this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
     }
     catch (e) {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
@@ -347,8 +353,12 @@ export default class PageContainer extends Container {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+      lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
     };
 
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
@@ -441,7 +451,6 @@ export default class PageContainer extends Container {
   async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
 
     if (editorMode === 'hackmd') {
@@ -663,4 +672,21 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
+  async resolveConflict(markdown, editorMode) {
+
+    const { pageId, remoteRevisionId, path } = this.state;
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const options = editorContainer.getCurrentOptionsToSave();
+    const optionsToSave = Object.assign({}, options);
+
+    const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
+
+    editorContainer.clearDraft(path);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+
+    editorContainer.setState({ tags: res.tags });
+
+    return res;
+  }
+
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
   code;
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
 
     this.message = message;
     this.code = code;
+    this.data = data;
+
   }
 
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
   }
 

+ 0 - 33
packages/app/src/components/ExpandOrContractButton.jsx

@@ -1,33 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-function ExpandOrContractButton(props) {
-  const { isWindowExpanded, contractWindow, expandWindow } = props;
-
-  const clickContractButtonHandler = () => {
-    if (contractWindow != null) {
-      contractWindow();
-    }
-  };
-
-  const clickExpandButtonHandler = () => {
-    if (expandWindow != null) {
-      expandWindow();
-    }
-  };
-
-  return (
-    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
-      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-    </button>
-  );
-}
-
-ExpandOrContractButton.propTypes = {
-  isWindowExpanded: PropTypes.bool,
-  contractWindow: PropTypes.func,
-  expandWindow: PropTypes.func,
-};
-
-
-export default ExpandOrContractButton;

+ 37 - 0
packages/app/src/components/ExpandOrContractButton.tsx

@@ -0,0 +1,37 @@
+import React, { FC } from 'react';
+
+
+type Props = {
+  isWindowExpanded: boolean,
+  contractWindow?: () => void,
+  expandWindow?: () => void,
+};
+
+const ExpandOrContractButton: FC<Props> = (props: Props) => {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = (): void => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = (): void => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="close"
+      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+    >
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+};
+
+
+export default ExpandOrContractButton;

+ 35 - 45
packages/app/src/components/PageEditor/AbstractEditor.jsx → packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,11 +1,20 @@
-/* eslint-disable react/no-unused-prop-types */
-
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
-import PropTypes from 'prop-types';
+import { ICodeMirror } from 'react-codemirror2';
+
+
+export interface AbstractEditorProps extends ICodeMirror {
+  value?: string;
+  isGfmMode?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
+}
 
-export default class AbstractEditor extends React.Component {
+export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
-  constructor(props) {
+  constructor(props: Readonly<T>) {
     super(props);
 
     this.forceToFocus = this.forceToFocus.bind(this);
@@ -20,91 +29,87 @@ export default class AbstractEditor extends React.Component {
     this.dispatchSave = this.dispatchSave.bind(this);
   }
 
-  forceToFocus() {
-  }
+  forceToFocus(): void {}
 
   /**
    * set new value
    */
-  setValue(newValue) {
-  }
+  setValue(_newValue: string): void {}
 
   /**
    * Enable/Disable GFM mode
-   * @param {bool} bool
+   * @param {bool} _bool
    */
-  setGfmMode(bool) {
-  }
+  setGfmMode(_bool: boolean): void {}
 
   /**
    * set caret position of codemirror
    * @param {string} number
    */
-  setCaretLine(line) {
-  }
+  setCaretLine(_line: number): void {}
 
   /**
    * scroll
-   * @param {number} line
+   * @param {number} _line
    */
-  setScrollTopByLine(line) {
-  }
+  setScrollTopByLine(_line: number): void {}
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBol() {
+  getStrFromBol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from current position to EOL(end of line)
    */
-  getStrToEol() {
+  getStrToEol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBolToSelectedUpperPos() {
+  getStrFromBolToSelectedUpperPos(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace Beggining Of Line to current position with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceBolToCurrentPos(text) {
+  replaceBolToCurrentPos(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace the current line with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceLine(text) {
+  replaceLine(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert text
-   * @param {string} text
+   * @param {string} _text
    */
-  insertText(text) {
+  insertText(_text: string): Error {
+    throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert line break to the current position
    */
-  insertLinebreak() {
+  insertLinebreak(): void {
     this.insertText('\n');
   }
 
   /**
    * dispatch onSave event
    */
-  dispatchSave() {
+  dispatchSave(): void {
     if (this.props.onSave != null) {
       this.props.onSave();
     }
@@ -114,7 +119,7 @@ export default class AbstractEditor extends React.Component {
    * dispatch onPasteFiles event
    * @param {object} event
    */
-  dispatchPasteFiles(event) {
+  dispatchPasteFiles(event: Event): void {
     if (this.props.onPasteFiles != null) {
       this.props.onPasteFiles(event);
     }
@@ -123,23 +128,8 @@ export default class AbstractEditor extends React.Component {
   /**
    * returns items(an array of react elements) in navigation bar for editor
    */
-  getNavbarItems() {
+  getNavbarItems(): null {
     return null;
   }
 
 }
-
-AbstractEditor.propTypes = {
-  value: PropTypes.string,
-  isGfmMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
-  onPasteFiles: PropTypes.func,
-  onDragEnter: PropTypes.func,
-  onCtrlEnter: PropTypes.func,
-};
-AbstractEditor.defaultProps = {
-  isGfmMode: true,
-};

+ 2 - 9
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -5,7 +5,6 @@ import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
 
 import { Button } from 'reactstrap';
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import { JSHINT } from 'jshint';
 
@@ -32,6 +31,7 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 // Textlint
 window.JSHINT = JSHINT;
@@ -908,7 +908,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   render() {
-    const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
@@ -924,7 +923,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return (
       <React.Fragment>
 
-        <ReactCodeMirror
+        <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           className={additionalClasses}
           placeholder="search"
@@ -935,12 +934,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
           }}
           value={this.state.value}
           options={{
-            mode,
-            theme: this.props.editorOptions.theme,
-            styleActiveLine: this.props.editorOptions.styleActiveLine,
-            lineNumbers: this.props.lineNumbers,
-            tabSize: 4,
-            indentUnit: this.props.indentSize,
             lineWrapping: true,
             scrollPastEnd: true,
             autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei

+ 282 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,282 @@
+import React, {
+  useState, useEffect, FC, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { UserPicture } from '@growi/ui';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+import CodeMirror from 'codemirror/lib/codemirror';
+
+import PageContainer from '../../client/services/PageContainer';
+import AppContainer from '../../client/services/AppContainer';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { useEditorMode } from '~/stores/ui';
+
+import { IRevisionOnConflict } from '../../interfaces/revision';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
+
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/merge/merge');
+require('codemirror/addon/merge/merge.css');
+const DMP = require('diff_match_patch');
+
+Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
+
+type ConflictDiffModalProps = {
+  isOpen: boolean | null;
+  onClose?: (() => void);
+  pageContainer: PageContainer;
+  appContainer: AppContainer;
+  markdownOnEdit: string;
+};
+
+type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
+  createdAt: string
+}
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [resolvedRevision, setResolvedRevision] = useState<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
+  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
+
+  const { data: editorMode } = useEditorMode();
+
+  const uncontrolledRef = useRef<CodeMirror>(null);
+
+  const { pageContainer, appContainer } = props;
+
+  const currentTime: Date = new Date();
+
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: appContainer.currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.revisionId || '',
+    revisionBody: pageContainer.state.markdown || '',
+    createdAt: pageContainer.state.updatedAt || '',
+    user: pageContainer.state.revisionAuthor,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.remoteRevisionId || '',
+    revisionBody: pageContainer.state.remoteRevisionBody || '',
+    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: pageContainer.state.lastUpdateUser,
+  };
+
+  useEffect(() => {
+    if (codeMirrorRef != null) {
+      CodeMirror.MergeView(codeMirrorRef, {
+        value: origin.revisionBody,
+        origLeft: request.revisionBody,
+        origRight: latest.revisionBody,
+        lineNumbers: true,
+        collapseIdentical: true,
+        showDifferences: true,
+        highlightDifferences: true,
+        connect: 'connect',
+        readOnly: true,
+        revertButtons: false,
+      });
+    }
+  }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
+
+  const onClose = () => {
+    if (props.onClose != null) {
+      props.onClose();
+    }
+  };
+
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+
+    const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
+
+    try {
+      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      onClose();
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
+    }
+
+  };
+
+  const onExpandModal = () => {
+    setIsModalExpanded(true);
+  };
+
+  const onContractModal = () => {
+    setIsModalExpanded(false);
+  };
+
+  const resizeAndCloseButtons = (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isModalExpanded}
+        expandWindow={onExpandModal}
+        contractWindow={onContractModal}
+      />
+      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      isOpen={props.isOpen || false}
+      toggle={onClose}
+      backdrop="static"
+      className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
+        <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
+      </ModalHeader>
+      <ModalBody className="mx-4 my-1">
+        { props.isOpen
+        && (
+          <div className="row">
+            <div className="col-12 text-center mt-2 mb-4">
+              <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={request.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {request.user.username}</p>
+                  <p className="my-0">{request.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={origin.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {origin.user.username}</p>
+                  <p className="my-0">{origin.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={latest.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {latest.user.username}</p>
+                  <p className="my-0">{latest.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(request.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(origin.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(latest.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-12">
+              <div className="border border-dark">
+                <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <UncontrolledCodeMirror
+                  ref={uncontrolledRef}
+                  value={resolvedRevision}
+                  options={{
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onClose: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markdownOnEdit: PropTypes.string.isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 100 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,8 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +20,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 class Editor extends AbstractEditor {
 
@@ -276,6 +279,7 @@ class Editor extends AbstractEditor {
     );
   }
 
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -286,88 +290,97 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
 
     return (
-      <div style={flexContainer} className="editor-container">
-        <Dropzone
-          ref={(c) => { this.dropzone = c }}
-          accept={this.getAcceptableType()}
-          noClick
-          noKeyboard
-          multiple={false}
-          onDragLeave={this.dragLeaveHandler}
-          onDrop={this.dropHandler}
-        >
-          {({
-            getRootProps,
-            getInputProps,
-            isDragAccept,
-            isDragReject,
-          }) => {
-            return (
-              <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                { this.state.isComponentDidMount && this.renderNavbar() }
-
-                {/* for PC */}
-                { !isMobile && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* for mobile */}
-                { isMobile && (
-                  <TextAreaEditor
-                    ref={(c) => { this.taEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                )}
-
-                <input {...getInputProps()} />
-              </div>
-            );
-          }}
-        </Dropzone>
-
-        { this.props.isUploadable
-          && (
-            <button
-              type="button"
-              className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={this.addAttachmentHandler}
-            >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-              Attach files
-              <span className="d-none d-sm-inline">
-              &nbsp;by dragging &amp; dropping,&nbsp;
-                <span className="btn-link">selecting them</span>,&nbsp;
-                or pasting from the clipboard.
-              </span>
-
-            </button>
-          )
-        }
-
-        { this.renderCheatsheetModal() }
-
-      </div>
+      <>
+        <div style={flexContainer} className="editor-container">
+          <Dropzone
+            ref={(c) => { this.dropzone = c }}
+            accept={this.getAcceptableType()}
+            noClick
+            noKeyboard
+            multiple={false}
+            onDragLeave={this.dragLeaveHandler}
+            onDrop={this.dropHandler}
+          >
+            {({
+              getRootProps,
+              getInputProps,
+              isDragAccept,
+              isDragReject,
+            }) => {
+              return (
+                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
+
+                  { this.state.isComponentDidMount && this.renderNavbar() }
+
+                  {/* for PC */}
+                  { !isMobile && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* for mobile */}
+                  { isMobile && (
+                    <TextAreaEditor
+                      ref={(c) => { this.taEditor = c }}
+                      onPasteFiles={this.pasteFilesHandler}
+                      onDragEnter={this.dragEnterHandler}
+                      {...this.props}
+                    />
+                  )}
+
+                  <input {...getInputProps()} />
+                </div>
+              );
+            }}
+          </Dropzone>
+
+          { this.props.isUploadable
+            && (
+              <button
+                type="button"
+                className="btn btn-outline-secondary btn-block btn-open-dropzone"
+                onClick={this.addAttachmentHandler}
+              >
+                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+                Attach files
+                <span className="d-none d-sm-inline">
+                &nbsp;by dragging &amp; dropping,&nbsp;
+                  <span className="btn-link">selecting them</span>,&nbsp;
+                  or pasting from the clipboard.
+                </span>
+
+              </button>
+            )
+          }
+
+          { this.renderCheatsheetModal() }
+
+        </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.props.value}
+        />
+      </>
     );
   }
 
@@ -375,6 +388,8 @@ class Editor extends AbstractEditor {
 
 Editor.propTypes = Object.assign({
   noCdn: PropTypes.bool,
+  // this value is markdown
+  value: PropTypes.string,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
@@ -382,6 +397,8 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);

+ 52 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -26,14 +26,22 @@ class PageStatusAlert extends React.Component {
     };
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
+    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
+  onClickResolveConflict() {
+    this.props.pageContainer.setState({
+      isConflictDiffModalOpen: true,
+    });
+  }
+
   getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
     return [
@@ -49,6 +57,45 @@ class PageStatusAlert extends React.Component {
     ];
   }
 
+  getContentsForRevisionOutdated() {
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let markdownOnEdit = '';
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        {isConflictOnEdit
+          && (
+            <button
+              type="button"
+              onClick={this.onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )
+        }
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return [
@@ -92,8 +139,12 @@ class PageStatusAlert extends React.Component {
 
     let getContentsFunc = null;
 
+    // when conflicting on save
+    if (isRevisionOutdated) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD

+ 8 - 0
packages/app/src/components/SavePageControls.jsx

@@ -66,6 +66,14 @@ class SavePageControls extends React.Component {
     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,
+        });
+      }
     }
   }
 

+ 58 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -0,0 +1,58 @@
+import React, { forwardRef, ReactNode, Ref } from 'react';
+import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import { Container, Subscribe } from 'unstated';
+import EditorContainer from '~/client/services/EditorContainer';
+import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+
+window.CodeMirror = require('codemirror');
+require('codemirror/addon/display/placeholder');
+require('~/client/util/codemirror/gfm-growi.mode');
+
+export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+  value: string;
+  options?: ICodeMirror['options'];
+  isGfmMode?: boolean;
+  indentSize?: number;
+  lineNumbers?: boolean;
+}
+
+interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
+  editorContainer: Container<EditorContainer>;
+  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
+}
+
+class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+
+  render(): ReactNode {
+
+    const {
+      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+    } = this.props;
+
+    const { editorOptions } = editorContainer.state;
+
+    return (
+      <CodeMirror
+        ref={forwardedRef}
+        value={value}
+        options={{
+          lineNumbers: lineNumbers ?? true,
+          mode: isGfmMode ? 'gfm-growi' : undefined,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          tabSize: 4,
+          indentUnit: indentSize,
+          ...options,
+        }}
+        {...rest}
+      />
+    );
+  }
+
+}
+
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
+  <Subscribe to={[EditorContainer]}>
+    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
+  </Subscribe>
+));

+ 7 - 0
packages/app/src/interfaces/revision.ts

@@ -7,3 +7,10 @@ export type IRevision = {
   createdAt: Date,
   updatedAt: Date,
 }
+
+export type IRevisionOnConflict = {
+  revisionId: string,
+  revisionBody: string,
+  createdAt: Date,
+  user: IUser
+}

+ 5 - 1
packages/app/src/server/models/vo/s2c-message.js

@@ -10,15 +10,19 @@ class S2cMessagePageUpdated {
     const serializedPage = serializePageSecurely(page);
 
     const {
-      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+      _id, revision, updatedAt, revisionHackmdSynced, hasDraftOnHackmd,
     } = serializedPage;
 
     this.pageId = _id;
     this.revisionId = revision;
+    this.revisionBody = page.revision.body;
+    this.revisionUpdateAt = updatedAt;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
 
     if (user != null) {
+      this.remoteLastUpdateUser = user;
+      // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       this.lastUpdateUsername = user.name;
     }
   }

+ 9 - 2
packages/app/src/server/routes/page.js

@@ -828,9 +828,17 @@ module.exports = function(crowi, app) {
     }
 
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const latestRevision = await Revision.findById(page.revision).populate('author');
+      const returnLatestRevision = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: xss.process(latestRevision.body),
+        createdAt: latestRevision.createdAt,
+        user: serializeUserSecurely(latestRevision.author),
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
     const options = { isSyncRevisionToHackmd };
@@ -839,7 +847,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 }
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
 
   result.ok = false;
   result.code = code;
+  result.data = data;
 
   if (err instanceof Error) {
     result.error = err.toString();

+ 5 - 0
yarn.lock

@@ -6975,6 +6975,11 @@ diff@^5.0.0:
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
 
+diff_match_patch@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/diff_match_patch/-/diff_match_patch-0.1.1.tgz#d3f14d5b76fb4b5a9cf44706261dadb5bd97edbc"
+  integrity sha1-0/FNW3b7S1qc9EcGJh2ttb2X7bw=
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"