yuken 3 лет назад
Родитель
Сommit
306262ecff
1 измененных файлов с 224 добавлено и 266 удалено
  1. 224 266
      packages/app/src/components/PageComment/CommentEditor.tsx

+ 224 - 266
packages/app/src/components/PageComment/CommentEditor.jsx → packages/app/src/components/PageComment/CommentEditor.tsx

@@ -1,7 +1,8 @@
-import React, { useCallback } from 'react';
+import React, {
+  useCallback, useState, useRef, useEffect,
+} from 'react';
 
 import { UserPicture } from '@growi/ui';
-import PropTypes from 'prop-types';
 import {
   Button,
   TabContent, TabPane,
@@ -14,6 +15,8 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { apiPostForm } from '~/client/util/apiv1-client';
+import { CustomWindow } from '~/interfaces/global';
+import InterceptorManager from '~/services/interceptor-manager';
 import { useCurrentPagePath, useCurrentPageId, useCurrentUser } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
@@ -40,145 +43,174 @@ const navTabMapping = {
   },
 };
 
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {React.Component}
- */
+type PropsType = {
+  appContainer: AppContainer,
+  commentContainer: CommentContainer,
 
-class CommentEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.appContainer.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isReadyToUse: !this.props.isForNewComment,
-      comment: this.props.commentBody || '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      isSlackConfigured: config.isSlackConfigured,
-      slackChannels: this.props.slackChannels,
-    };
+  growiRenderer: GrowiRenderer,
+  isForNewComment: boolean,
+  replyTo: string,
+  currrentCommentId: string,
+  commentBody: string,
+  commentCreator: string,
+  onCancelButtonClicked: (id: string) => void,
+  onCommentButtonClicked: () => void,
 
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+  currentCommentId: string
+}
 
-    this.cancelButtonClickedHandler = this.cancelButtonClickedHandler.bind(this);
-    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
-    this.ctrlEnterHandler = this.ctrlEnterHandler.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.uploadHandler = this.uploadHandler.bind(this);
+interface ICommentEditorOperation {
+  setGfmMode: (value: boolean) => void,
+  setValue: (value: string) => void,
+  insertText: (text: string) => void,
+  terminateUploadingState: () => void,
+}
 
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
-  }
+const CommentEditor = (props: PropsType): JSX.Element => {
 
-  updateState(value) {
-    this.setState({ comment: value });
-  }
+  const {
+    appContainer, commentContainer, growiRenderer, isForNewComment,
+    replyTo, currentCommentId, commentBody, commentCreator,
+  } = props;
+  const { data: currentUser } = useCurrentUser();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
+  const config = appContainer.getConfig();
+  const isUploadable = config.upload.image || config.upload.file;
+  const isUploadableFile = config.upload.file;
+  const isSlackConfigured = config.isSlackConfigured;
+
+  const [isReadyToUse, setIsReadyToUse] = useState(isForNewComment);
+  const [comment, setComment] = useState(commentBody ?? '');
+  const [isMarkdown, setIsMarkdown] = useState(false);
+  const [html, setHtml] = useState('');
+  const [activeTab, setActiveTab] = useState('comment_editor');
+  const [error, setError] = useState();
+  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
 
-  updateStateCheckbox(event) {
+  const editorRef = useRef<ICommentEditorOperation>(null);
+
+  // TODO: typescriptize Editor
+  const AnyEditor = Editor as any;
+
+  const updateState = (value:string) => {
+    setComment(value);
+  };
+
+  const updateStateCheckbox = (event) => {
+    if (editorRef.current == null) { return }
     const value = event.target.checked;
-    this.setState({ isMarkdown: value });
+    setIsMarkdown(value);
     // changeMode
-    this.editor.setGfmMode(value);
-  }
+    editorRef.current.setGfmMode(value);
+  };
+
+  const renderHtml = (markdown: string) => {
+    const context = {
+      markdown,
+      parsedHTML: '',
+    };
+
+    const interceptorManager: InterceptorManager = (window as CustomWindow).interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown, context);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        setHtml(context.parsedHTML);
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  };
 
-  handleSelect(activeTab) {
-    this.setState({ activeTab });
-    this.renderHtml(this.state.comment);
-  }
+  const handleSelect = (activeTab: string) => {
+    setActiveTab(activeTab);
+    renderHtml(comment);
+  };
 
-  fetchSlackChannels(slackChannels) {
-    this.setState({ slackChannels });
-  }
+  const fetchSlackChannels = (slackChannels: string|undefined) => {
+    if (slackChannels === undefined) { return }
+    setSlackChannels(slackChannels);
+  };
 
-  componentDidUpdate(prevProps) {
-    if (this.props.slackChannels !== prevProps.slackChannels) {
-      this.fetchSlackChannels(this.props.slackChannels);
-    }
-  }
-
-  onSlackChannelsChange(slackChannels) {
-    this.setState({ slackChannels });
-  }
-
-  initializeEditor() {
-    this.setState({
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      errorMessage: undefined,
-    });
+  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  }, [mutateIsSlackEnabled]);
+
+  useEffect(() => {
+    // if (this.props.slackChannels !== prevProps.slackChannels) {
+    //   this.fetchSlackChannels(this.props.slackChannels);
+    // }
+    // 実装を考える必要あり
+    fetchSlackChannels(slackChannelsData?.toString());
+  });
+
+  const onSlackChannelsChange = (slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  };
+
+  const initializeEditor = () => {
+    setComment('');
+    setIsMarkdown(true);
+    setHtml('');
+    setActiveTab('comment_editor');
+    setError(undefined);
     // reset value
-    this.editor.setValue('');
-  }
+    if (editorRef.current == null) { return }
+    editorRef.current.setValue('');
+  };
 
-  cancelButtonClickedHandler() {
-    const { isForNewComment, onCancelButtonClicked } = this.props;
+  const cancelButtonClickedHandler = () => {
+    const { onCancelButtonClicked } = props;
 
     // change state to not ready
     // when this editor is for the new comment mode
     if (isForNewComment) {
-      this.setState({ isReadyToUse: false });
+      setIsReadyToUse(false);
     }
 
     if (onCancelButtonClicked != null) {
-      const { replyTo, currentCommentId } = this.props;
       onCancelButtonClicked(replyTo || currentCommentId);
     }
-  }
-
-  commentButtonClickedHandler() {
-    this.postComment();
-  }
+  };
 
-  ctrlEnterHandler(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.postComment();
-  }
-
-  /**
-   * Post comment with CommentContainer and update state
-   */
-  async postComment() {
-    const {
-      commentContainer, replyTo, currentCommentId, commentCreator, onCommentButtonClicked,
-    } = this.props;
+  const postComment = async() => {
+    const { onCommentButtonClicked } = props;
     try {
       if (currentCommentId != null) {
         await commentContainer.putComment(
-          this.state.comment,
-          this.state.isMarkdown,
+          comment,
+          isMarkdown,
           currentCommentId,
           commentCreator,
         );
       }
       else {
-        await this.props.commentContainer.postComment(
-          this.state.comment,
-          this.state.isMarkdown,
+        await commentContainer.postComment(
+          comment,
+          isMarkdown,
           replyTo,
-          this.props.isSlackEnabled,
-          this.state.slackChannels,
+          isSlackEnabled,
+          slackChannels,
         );
       }
-      this.initializeEditor();
+      initializeEditor();
 
       if (onCommentButtonClicked != null) {
         onCommentButtonClicked();
@@ -186,20 +218,47 @@ class CommentEditor extends React.Component {
     }
     catch (err) {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
-      this.setState({ errorMessage });
+      setError(errorMessage);
+    }
+  };
+
+  const commentButtonClickedHandler = () => {
+    postComment();
+  };
+
+  const ctrlEnterHandler = (event) => {
+    if (event != null) {
+      event.preventDefault();
     }
-  }
 
-  async uploadHandler(file) {
-    const pagePath = this.props.currentPagePath;
-    const pageId = this.props.currentPageId;
+    postComment();
+  };
+
+  const apiErrorHandler = (error) => {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  };
+
+  const uploadHandler = async(file) => {
+
+    if (editorRef.current == null) { return }
+
+    const pagePath = currentPagePath;
+    const pageId = currentPageId;
     const endpoint = '/attachments.add';
     const formData = new FormData();
     formData.append('file', file);
-    formData.append('path', pagePath);
-    formData.append('page_id', pageId);
+    formData.append('path', pagePath ?? '');
+    formData.append('page_id', pageId ?? '');
     try {
-      const res = await apiPostForm(endpoint, formData);
+      // TODO: typescriptize res
+      const res = await apiPostForm(endpoint, formData) as any;
       const attachment = res.attachment;
       const fileName = attachment.originalName;
       let insertText = `[${fileName}](${attachment.filePathProxied})`;
@@ -208,93 +267,46 @@ class CommentEditor extends React.Component {
         // modify to "![fileName](url)" syntax
         insertText = `!${insertText}`;
       }
-      this.editor.insertText(insertText);
+      editorRef.current.insertText(insertText);
     }
     catch (err) {
-      this.apiErrorHandler(err);
+      apiErrorHandler(err);
     }
 
-    this.editor.terminateUploadingState();
-  }
+    editorRef.current.terminateUploadingState();
+  };
 
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  getCommentHtml() {
+  const getCommentHtml = () => {
     return (
       <CommentPreview
-        html={this.state.html}
+        html={html}
       />
     );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const { growiRenderer } = this.props;
-    const { interceptorManager } = window;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown, context);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown, context);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
+  };
 
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  renderBeforeReady() {
+  const renderBeforeReady = (): JSX.Element => {
     return (
       <div className="text-center">
         <NotAvailableForGuest>
           <button
             type="button"
             className="btn btn-lg btn-link"
-            onClick={() => this.setState({ isReadyToUse: true })}
+            onClick={() => setIsReadyToUse(true)}
           >
             <i className="icon-bubble"></i> Add Comment
           </button>
         </NotAvailableForGuest>
       </div>
     );
-  }
+  };
 
-  renderReady() {
-    const { isMobile } = this.props;
-    const { activeTab } = this.state;
+  const renderReady = () => {
 
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
+    const commentPreview = isMarkdown ? getCommentHtml() : null;
 
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
     const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
+      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={cancelButtonClickedHandler}>
         Cancel
       </Button>
     );
@@ -303,7 +315,7 @@ class CommentEditor extends React.Component {
         outline
         color="primary"
         className="btn btn-outline-primary rounded-pill"
-        onClick={this.commentButtonClickedHandler}
+        onClick={commentButtonClickedHandler}
       >
         Comment
       </Button>
@@ -313,20 +325,20 @@ class CommentEditor extends React.Component {
     return (
       <>
         <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
-              <Editor
-                ref={(c) => { this.editor = c }}
-                value={this.state.comment}
-                isGfmMode={this.state.isMarkdown}
+              <AnyEditor
+                ref={editorRef}
+                value={comment}
+                isGfmMode={isMarkdown}
                 lineNumbers={false}
                 isMobile={isMobile}
-                isUploadable={this.state.isUploadable}
-                isUploadableFile={this.state.isUploadableFile}
-                onChange={this.updateState}
-                onUpload={this.uploadHandler}
-                onCtrlEnter={this.ctrlEnterHandler}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
+                onChange={updateState}
+                onUpload={uploadHandler}
+                onCtrlEnter={ctrlEnterHandler}
                 isComment
               />
               {/*
@@ -352,9 +364,9 @@ class CommentEditor extends React.Component {
                     className="custom-control-input"
                     id="comment-form-is-markdown"
                     name="isMarkdown"
-                    checked={this.state.isMarkdown}
+                    checked={isMarkdown}
                     value="1"
-                    onChange={this.updateStateCheckbox}
+                    onChange={updateStateCheckbox}
                   />
                   <label
                     className="ml-2 custom-control-label"
@@ -366,16 +378,16 @@ class CommentEditor extends React.Component {
               ) }
             </label>
             <span className="flex-grow-1" />
-            <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
+            <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
-            { this.state.isSlackConfigured
+            { isSlackConfigured
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
-                    isSlackEnabled={this.props.isSlackEnabled}
-                    slackChannels={this.state.slackChannels}
-                    onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
-                    onChannelChange={this.onSlackChannelsChange}
+                    isSlackEnabled
+                    slackChannels={slackChannelsData?.toString() ?? ''}
+                    onEnabledFlagChange={onSlackEnabledFlagChange}
+                    onChannelChange={onSlackChannelsChange}
                     id="idForComment"
                   />
                 </div>
@@ -387,90 +399,36 @@ class CommentEditor extends React.Component {
           </div>
           <div className="d-block d-sm-none mt-2">
             <div className="d-flex justify-content-end">
-              { this.state.errorMessage && errorMessage }
+              { error && errorMessage }
               <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
             </div>
           </div>
         </div>
       </>
     );
-  }
-
-  render() {
-    const { currentUser } = this.props;
-    const { isReadyToUse } = this.state;
+  };
 
-    return (
-      <div className="form page-comment-form">
-        <div className="comment-form">
-          <div className="comment-form-user">
-            <UserPicture user={currentUser} noLink noTooltip />
-          </div>
-          <div className="comment-form-main">
-            { !isReadyToUse
-              ? this.renderBeforeReady()
-              : this.renderReady()
-            }
-          </div>
+  return (
+    <div className="form page-comment-form">
+      <div className="comment-form">
+        <div className="comment-form-user">
+          <UserPicture user={currentUser} noLink noTooltip />
+        </div>
+        <div className="comment-form-main">
+          { !isReadyToUse
+            ? renderBeforeReady()
+            : renderReady()
+          }
         </div>
       </div>
-    );
-  }
+    </div>
+  );
 
-}
+};
 
 /**
  * Wrapper component for using unstated
  */
-const CommentEditorHOCWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
-
-CommentEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-
-  currentPagePath: PropTypes.string.isRequired,
-  currentPageId: PropTypes.string.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
-  currentUser: PropTypes.instanceOf(Object),
-  isMobile: PropTypes.bool,
-  isForNewComment: PropTypes.bool,
-  replyTo: PropTypes.string,
-  currentCommentId: PropTypes.string,
-  commentBody: PropTypes.string,
-  commentCreator: PropTypes.string,
-  onCancelButtonClicked: PropTypes.func,
-  onCommentButtonClicked: PropTypes.func,
-  onSlackEnabledFlagChange: PropTypes.func,
-};
-
-const CommentEditorWrapper = (props) => {
-  const { data: isMobile } = useIsMobile();
-  const { data: currentUser } = useCurrentUser();
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: currentPageId } = useCurrentPageId();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
-    mutateIsSlackEnabled(isSlackEnabled, false);
-  }, [mutateIsSlackEnabled]);
-
-  return (
-    <CommentEditorHOCWrapper
-      {...props}
-      currentPagePath={currentPagePath}
-      currentPageId={currentPageId}
-      onSlackEnabledFlagChange={onSlackEnabledFlagChange}
-      slackChannels={slackChannelsData.toString()}
-      isSlackEnabled={isSlackEnabled}
-      currentUser={currentUser}
-      isMobile={isMobile}
-    />
-  );
-};
+const CommentEditorWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
 
 export default CommentEditorWrapper;