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

Merge pull request #440 from weseek/feat/Markdown-editor-for-comment

Feat/markdown editor for comment
Yuki Takei 7 лет назад
Родитель
Сommit
c702d66371

+ 2 - 1
lib/form/comment.js

@@ -7,5 +7,6 @@ module.exports = form(
   field('commentForm.page_id').trim().required(),
   field('commentForm.revision_id').trim().required(),
   field('commentForm.comment').trim().required(),
-  field('commentForm.comment_position').trim().toInt()
+  field('commentForm.comment_position').trim().toInt(),
+  field('commentForm.is_markdown').trim().toBooleanStrict()
 );

+ 4 - 2
lib/models/comment.js

@@ -12,10 +12,11 @@ module.exports = function(crowi) {
     revision: { type: ObjectId, ref: 'Revision', index: true },
     comment: { type: String, required: true },
     commentPosition: { type: Number, default: -1 },
-    createdAt: { type: Date, default: Date.now }
+    createdAt: { type: Date, default: Date.now },
+    isMarkdown: { type: Boolean, default: false}
   });
 
-  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position) {
+  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown) {
     var Comment = this,
       commentPosition = position || -1;
 
@@ -28,6 +29,7 @@ module.exports = function(crowi) {
       newComment.revision = revisionId;
       newComment.comment = comment;
       newComment.commentPosition = position;
+      newComment.isMarkdown = isMarkdown || false;
 
       newComment.save(function(err, data) {
         if (err) {

+ 4 - 3
lib/routes/comment.js

@@ -62,8 +62,9 @@ module.exports = function(crowi, app) {
     var revisionId = form.revision_id;
     var comment = form.comment;
     var position = form.comment_position || -1;
+    var isMarkdown = form.is_markdown;
 
-    return Comment.create(pageId, req.user._id, revisionId, comment, position)
+    return Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .then(function(createdComment) {
         createdComment.creator = req.user;
         return res.json(ApiResponse.success({comment: createdComment}));
@@ -92,11 +93,11 @@ module.exports = function(crowi, app) {
           return Page.updateCommentCount(comment.page);
         })
         .then(function() {
-          return res.json(ApiResponse.success({})); 
+          return res.json(ApiResponse.success({}));
         });
       })
       .catch(function(err) {
-        return res.json(ApiResponse.error(err)); 
+        return res.json(ApiResponse.error(err));
       });
 
   };

+ 1 - 26
lib/views/layout-growi/widget/comments.html

@@ -19,32 +19,7 @@
     </div>
 
     {% if page and not page.isDeleted() %}
-    <form class="form page-comment-form" id="page-comment-form" onsubmit="return false;">
-      <div class="comment-form">
-        <div class="comment-form-user">
-            <img src="{{ user|picture }}" class="picture img-circle" width="25" alt="{{ user.name }}" title="{{ user.name }}" />
-        </div>
-        <div class="comment-form-main">
-          <div class="comment-write" id="comment-write">
-            <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"
-                required placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
-          </div>
-          <div class="comment-submit">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
-            <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
-            <div class="pull-right">
-              <span class="text-danger" id="comment-form-message"></span>
-              <button type="submit" id="comment-form-button" class="fcbtn btn btn-sm btn-outline btn-rounded btn-primary btn-1b" {% if not user %}disabled{% endif %}>
-                Comment
-              </button>
-            </div>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </div>
-    </form>
-    <div id="page-comment-form-behavior"></div>
+    <div id="page-comment-write"></div>
     {% endif %}
 
   </div>

+ 13 - 5
resource/js/app.js

@@ -18,7 +18,7 @@ import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
-import PageCommentFormBehavior from './components/PageCommentFormBehavior';
+import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
@@ -134,10 +134,18 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// render components with refs to another component
-const elem = document.getElementById('page-comment-form-behavior');
-if (elem) {
-  ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, elem);
+// render comment form
+const writeCommentElem = document.getElementById('page-comment-write');
+if (writeCommentElem) {
+  const pageCommentsElem = componentInstances['page-comments-list'];
+  const postCompleteHandler = (comment) => {
+    if (pageCommentsElem != null) {
+      pageCommentsElem.retrieveData();
+    }
+  };
+  ReactDOM.render(
+    <CommentForm crowi={crowi} pageId={pageId} revisionId={pageRevisionId} onPostComplete={postCompleteHandler} crowiRenderer={crowiRenderer}/>,
+    writeCommentElem);
 }
 
 /*

+ 68 - 1
resource/js/components/PageComment/Comment.js

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 
 import dateFnsFormat from 'date-fns/format';
 
+import RevisionBody from '../Page/RevisionBody';
+
 import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 
@@ -19,11 +21,30 @@ export default class Comment extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      html: '',
+    };
+
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+  }
+
+  componentWillMount() {
+    this.renderHtml(this.props.comment.comment);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.comment.comment);
+  }
+
+  //not used
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.renderHtml(markdown);
   }
 
   isCurrentUserEqualsToAuthor() {
@@ -48,13 +69,57 @@ export default class Comment extends React.Component {
     this.props.deleteBtnClicked(this.props.comment);
   }
 
+  renderRevisionBody() {
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+    return (
+      <RevisionBody html={this.state.html}
+          inputRef={el => this.revisionBodyElement = el}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={true} />
+    );
+  }
+
+  renderHtml(markdown) {
+    var context = {
+      markdown,
+      dom: this.revisionBodyElement,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preCommentRender', context)
+      .then(() => interceptorManager.process('preCommentPreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown);
+      })
+      .then(() => interceptorManager.process('postCommentPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.process(context.markdown);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => interceptorManager.process('preCommentPostProcess', context))
+      .then(() => {
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+      })
+      .then(() => interceptorManager.process('postCommentPostProcess', context))
+      .then(() => interceptorManager.process('preCommentRenderHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postCommentRenderHtml', context));
+
+  }
+
   render() {
     const comment = this.props.comment;
     const creator = comment.creator;
+    const isMarkdown = comment.isMarkdown;
 
     const rootClassName = this.getRootClassName();
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
-    const commentBody = ReactUtils.nl2br(comment.comment);
+    const commentBody = isMarkdown ? this.renderRevisionBody(): ReactUtils.nl2br(comment.comment);
     const creatorsPage = `/user/${creator.username}`;
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
@@ -90,4 +155,6 @@ Comment.propTypes = {
   currentRevisionId: PropTypes.string.isRequired,
   currentUserId: PropTypes.string.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
 };

+ 183 - 0
resource/js/components/PageComment/CommentForm.js

@@ -0,0 +1,183 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ReactUtils from '../ReactUtils';
+
+import CommentPreview from '../PageComment/CommentPreview';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import UserPicture from '../User/UserPicture';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class Comment
+ * @extends {React.Component}
+ */
+
+export default class CommentForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.postComment = this.postComment.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  updateState(event) {
+    const target = event.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({
+      [name]: value
+    });
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(event) {
+    event.preventDefault();
+    this.props.crowi.apiPost('/comments.add', {
+      commentForm: {
+        comment: this.state.comment,
+        _csrf: this.props.crowi.csrfToken,
+        page_id: this.props.pageId,
+        revision_id: this.props.revisionId,
+        is_markdown: this.state.isMarkdown,
+      }
+    })
+      .then((res) => {
+        if (this.props.onPostComplete != null) {
+          this.props.onPostComplete(res.comment);
+        }
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+        });
+      });
+  }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        html={this.state.html}
+        inputRef={el => this.previewElement = el}/>
+    );
+  }
+
+  renderHtml(markdown) {
+    var context = {
+      markdown,
+      dom: this.previewElement,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preCommnetFormPreviewRender', context)
+      .then(() => interceptorManager.process('preCommnetFormPreviewPreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown);
+      })
+      .then(() => interceptorManager.process('postCommnetFormPreviewPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.process(context.markdown);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => interceptorManager.process('preCommnetFormPreviewPostProcess', context))
+      .then(() => {
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+      })
+      .then(() => interceptorManager.process('postCommnetFormPreviewPostProcess', context))
+      .then(() => interceptorManager.process('preCommnetFormPreviewRenderHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postCommnetFormPreviewRenderHtml', context));
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+
+  render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const creatorsPage = `/user/${username}`;
+    const comment = this.state.comment;
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
+    return (
+      <div>
+        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
+          { username &&
+            <div className="comment-form">
+              <div className="comment-form-user">
+                <a href={creatorsPage}>
+                  <UserPicture user={user} />
+                </a>
+              </div>
+              <div className="comment-form-main">
+                <div className="comment-write">
+                  <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                    <Tab eventKey={1} title="Write">
+                      <textarea className="comment-form-comment form-control" id="comment-form-comment" name="comment" required placeholder="Write comments here..." value={this.state.comment} onChange={this.updateState} >
+                      </textarea>
+                      <div className="form-check">
+                        <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateState} /> Markdown<br />
+                      </div>
+                    </Tab>
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                      {commentPreview}
+                      </div>
+                    </Tab>
+                  </Tabs>
+                </div>
+                <div className="comment-submit">
+                  <div className="pull-right">
+                    <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+                        Comment
+                    </Button>
+                  </div>
+                  <div className="clearfix">
+                  </div>
+                </div>
+              </div>
+            </div>
+          }
+        </form>
+      </div>
+    );
+  }
+}
+
+CommentForm.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  onPostComplete: PropTypes.func,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  crowiRenderer:  PropTypes.object.isRequired,
+};

+ 34 - 0
resource/js/components/PageComment/CommentPreview.js

@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionBody from '../Page/RevisionBody';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
+export default class CommentPreview extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    return (
+      <div className="page-comment-preview-body"
+          ref={(elm) => {
+            this.previewElement = elm;
+            this.props.inputRef(elm);
+          }}>
+
+        <RevisionBody
+          {...this.props}
+        />
+      </div>
+    );
+  }
+}
+
+CommentPreview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired,  // for getting div element
+};

+ 0 - 65
resource/js/components/PageCommentFormBehavior.js

@@ -1,65 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageComments from './PageComments';
-
-/**
- * Set the behavior that post comments to #page-comment-form
- *
- * This is transplanted from legacy/crowi.js -- 2017.06.03 Yuki Takei
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageCommentFormBehavior
- * @extends {React.Component}
- */
-export default class PageCommentFormBehavior extends React.Component {
-
-  constructor(props) {
-    super(props);
-  }
-
-  componentWillMount() {
-    const pageComments = this.props.pageComments;
-
-    if (pageComments === undefined) {
-      return;
-    }
-
-    $('#page-comment-form').on('submit', function() {
-      var $button = $('#comment-form-button');
-      $button.attr('disabled', 'disabled');
-      $.post('/_api/comments.add', $(this).serialize(), function(data) {
-        $button.prop('disabled', false);
-        if (data.ok) {
-
-          // reload comments
-          pageComments.init();
-
-          $('#comment-form-comment').val('');
-          $('#comment-form-message').text('');
-        }
-        else {
-          $('#comment-form-message').text(data.error);
-        }
-      }).fail(function(data) {
-        if (data.status !== 200) {
-          $('#comment-form-message').text(data.statusText);
-        }
-      });
-
-      return false;
-    });
-  }
-
-  render() {
-    // render nothing
-    return <div></div>;
-  }
-}
-
-PageCommentFormBehavior.propTypes = {
-  pageComments: PropTypes.instanceOf(PageComments),
-  crowi: PropTypes.object.isRequired,
-};

+ 22 - 12
resource/js/components/PageComments.js

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import GrowiRenderer from '../util/GrowiRenderer';
+
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 
@@ -30,6 +32,8 @@ export default class PageComments extends React.Component {
       errorMessageForDeleting: undefined,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, null, {mode: 'comment'});
+
     this.init = this.init.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
     this.deleteComment = this.deleteComment.bind(this);
@@ -43,6 +47,8 @@ export default class PageComments extends React.Component {
     if (pageId) {
       this.init();
     }
+
+    this.retrieveData = this.retrieveData.bind(this);
   }
 
   init() {
@@ -50,21 +56,23 @@ export default class PageComments extends React.Component {
       return ;
     }
 
-    const pageId = this.props.pageId;
-
     const layoutType = this.props.crowi.getConfig()['layoutType'];
     this.setState({isLayoutTypeGrowi: 'crowi-plus' === layoutType || 'growi' === layoutType});
 
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', {page_id: pageId})
-    .then(res => {
-      if (res.ok) {
-        this.setState({comments: res.comments});
-      }
-    }).catch(err => {
-
-    });
+    this.retrieveData();
+  }
 
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveData() {
+    // get data (desc order array)
+    this.props.crowi.apiGet('/comments.get', {page_id: this.props.pageId})
+      .then(res => {
+        if (res.ok) {
+          this.setState({comments: res.comments});
+        }
+      });
   }
 
   confirmToDeleteComment(comment) {
@@ -123,7 +131,9 @@ export default class PageComments extends React.Component {
         <Comment key={comment._id} comment={comment}
           currentUserId={this.props.crowi.me}
           currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment} />
+          deleteBtnClicked={this.confirmToDeleteComment}
+          crowi={this.props.crowi}
+          crowiRenderer={this.growiRenderer} />
       );
     });
   }

+ 5 - 0
resource/js/util/GrowiRenderer.js

@@ -90,6 +90,11 @@ export default class GrowiRenderer {
           new HeaderLineNumberConfigurer(crowi)
         ]);
         break;
+      case 'comment':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new HeaderLineNumberConfigurer(crowi),
+        ]);
+        break;
       default:
         break;
     }