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

Merge pull request #6071 from weseek/imprv/97999-refactor-CommentEditor-FC-and-TS

imprv: 97999 refactor comment editor fc and ts
yuken 3 лет назад
Родитель
Сommit
d92ebf29ed

+ 9 - 9
packages/app/src/components/Drawio.tsx

@@ -7,13 +7,10 @@ import EventEmitter from 'events';
 import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 
-import NotAvailableForGuest from './NotAvailableForGuest';
-
+import { CustomWindow } from '~/interfaces/global';
+import { IGraphViewer } from '~/interfaces/graph-viewer';
 
-declare const globalEmitter: EventEmitter;
-declare const GraphViewer: {
-  createViewerForElement: (Element) => void,
-};
+import NotAvailableForGuest from './NotAvailableForGuest';
 
 type Props = {
   drawioContent: string,
@@ -31,10 +28,13 @@ const Drawio = (props: Props): JSX.Element => {
 
   const drawioContainerRef = useRef<HTMLDivElement>(null);
 
+  const globalEmitter: EventEmitter = useMemo(() => (window as CustomWindow).globalEmitter, []);
+  const GraphViewer: IGraphViewer = useMemo(() => (window as CustomWindow).GraphViewer, []);
+
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown]);
+  }, [rangeLineNumberOfMarkdown, globalEmitter]);
 
   const renderDrawio = useCallback(() => {
     if (drawioContainerRef.current == null) {
@@ -51,7 +51,7 @@ const Drawio = (props: Props): JSX.Element => {
         GraphViewer.createViewerForElement(div);
       }
     }
-  }, []);
+  }, [GraphViewer]);
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
@@ -61,7 +61,7 @@ const Drawio = (props: Props): JSX.Element => {
     }
 
     renderDrawioWithDebounce();
-  }, [renderDrawioWithDebounce]);
+  }, [renderDrawioWithDebounce, GraphViewer]);
 
   return (
     <div className="editable-with-drawio position-relative">

+ 0 - 477
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,477 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import PropTypes from 'prop-types';
-import {
-  Button,
-  TabContent, TabPane,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import AppContainer from '~/client/services/AppContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-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 { useCurrentPagePath, useCurrentPageId, useCurrentUser } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsMobile } from '~/stores/ui';
-
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
-import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
-import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import CommentPreview from './CommentPreview';
-
-
-const navTabMapping = {
-  comment_editor: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Write',
-    index: 0,
-  },
-  comment_preview: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Preview',
-    index: 1,
-  },
-};
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {React.Component}
- */
-
-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,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-
-    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);
-
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(activeTab) {
-    this.setState({ activeTab });
-    this.renderHtml(this.state.comment);
-  }
-
-  fetchSlackChannels(slackChannels) {
-    this.setState({ 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,
-    });
-    // reset value
-    this.editor.setValue('');
-  }
-
-  cancelButtonClickedHandler() {
-    const { isForNewComment, onCancelButtonClicked } = this.props;
-
-    // change state to not ready
-    // when this editor is for the new comment mode
-    if (isForNewComment) {
-      this.setState({ isReadyToUse: 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;
-    try {
-      if (currentCommentId != null) {
-        await commentContainer.putComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          currentCommentId,
-          commentCreator,
-        );
-      }
-      else {
-        await this.props.commentContainer.postComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          replyTo,
-          this.props.isSlackEnabled,
-          this.state.slackChannels,
-        );
-      }
-      this.initializeEditor();
-
-      if (onCommentButtonClicked != null) {
-        onCommentButtonClicked();
-      }
-    }
-    catch (err) {
-      const errorMessage = err.message || 'An unknown error occured when posting comment';
-      this.setState({ errorMessage });
-    }
-  }
-
-  async uploadHandler(file) {
-    const pagePath = this.props.currentPagePath;
-    const pageId = this.props.currentPageId;
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath);
-    formData.append('page_id', pageId);
-    try {
-      const res = await apiPostForm(endpoint, formData);
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
-      }
-      this.editor.insertText(insertText);
-    }
-    catch (err) {
-      this.apiErrorHandler(err);
-    }
-    finally {
-      this.editor.terminateUploadingState();
-    }
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        html={this.state.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() {
-    return (
-      <div className="text-center">
-        <NotAvailableForGuest>
-          <button
-            type="button"
-            className="btn btn-lg btn-link"
-            onClick={() => this.setState({ isReadyToUse: true })}
-          >
-            <i className="icon-bubble"></i> Add Comment
-          </button>
-        </NotAvailableForGuest>
-      </div>
-    );
-  }
-
-  renderReady() {
-    const { isMobile } = this.props;
-    const { activeTab } = this.state;
-
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
-        Cancel
-      </Button>
-    );
-    const submitButton = (
-      <Button
-        outline
-        color="primary"
-        className="btn btn-outline-primary rounded-pill"
-        onClick={this.commentButtonClickedHandler}
-      >
-        Comment
-      </Button>
-    );
-
-
-    return (
-      <>
-        <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="comment_editor">
-              <Editor
-                ref={(c) => { this.editor = c }}
-                value={this.state.comment}
-                isGfmMode={this.state.isMarkdown}
-                lineNumbers={false}
-                isMobile={isMobile}
-                isUploadable={this.state.isUploadable}
-                isUploadableFile={this.state.isUploadableFile}
-                onChange={this.updateState}
-                onUpload={this.uploadHandler}
-                onCtrlEnter={this.ctrlEnterHandler}
-                isComment
-              />
-              {/*
-                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
-                See a review comment in https://github.com/weseek/growi/pull/3473
-              */}
-            </TabPane>
-            <TabPane tabId="comment_preview">
-              <div className="comment-form-preview">
-                {commentPreview}
-              </div>
-            </TabPane>
-          </TabContent>
-        </div>
-
-        <div className="comment-submit">
-          <div className="d-flex">
-            <label className="mr-2">
-              {activeTab === 'comment_editor' && (
-                <span className="custom-control custom-checkbox">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="comment-form-is-markdown"
-                    name="isMarkdown"
-                    checked={this.state.isMarkdown}
-                    value="1"
-                    onChange={this.updateStateCheckbox}
-                  />
-                  <label
-                    className="ml-2 custom-control-label"
-                    htmlFor="comment-form-is-markdown"
-                  >
-                    Markdown
-                  </label>
-                </span>
-              ) }
-            </label>
-            <span className="flex-grow-1" />
-            <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
-
-            { this.state.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}
-                    id="idForComment"
-                  />
-                </div>
-              )
-            }
-            <div className="d-none d-sm-block">
-              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-          <div className="d-block d-sm-none mt-2">
-            <div className="d-flex justify-content-end">
-              { this.state.errorMessage && 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>
-        </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}
-    />
-  );
-};
-
-export default CommentEditorWrapper;

+ 411 - 0
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -0,0 +1,411 @@
+import React, {
+  useCallback, useState, useRef, useEffect,
+} from 'react';
+
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  TabContent, TabPane,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import AppContainer from '~/client/services/AppContainer';
+import CommentContainer from '~/client/services/CommentContainer';
+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 { IInterceptorManager } from '~/interfaces/interceptor-manager';
+import { useCurrentPagePath, useCurrentPageId, useCurrentUser } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import NotAvailableForGuest from '../NotAvailableForGuest';
+import Editor from '../PageEditor/Editor';
+import { SlackNotification } from '../SlackNotification';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import CommentPreview from './CommentPreview';
+
+
+const navTabMapping = {
+  comment_editor: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Write',
+    index: 0,
+  },
+  comment_preview: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Preview',
+    index: 1,
+  },
+};
+
+type PropsType = {
+  appContainer: AppContainer,
+  commentContainer: CommentContainer,
+
+  growiRenderer: GrowiRenderer,
+  isForNewComment?: boolean,
+  replyTo?: string,
+  currentCommentId?: string,
+  commentBody?: string,
+  commentCreator?: string,
+  onCancelButtonClicked?: () => void,
+  onCommentButtonClicked?: () => void,
+}
+
+type EditorRef = {
+  setGfmMode: (value: boolean) => void,
+  setValue: (value: string) => void,
+  insertText: (text: string) => void,
+  terminateUploadingState: () => void,
+}
+
+const CommentEditor = (props: PropsType): JSX.Element => {
+
+  const {
+    appContainer, commentContainer, growiRenderer, isForNewComment, replyTo,
+    currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
+  } = 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());
+
+  const editorRef = useRef<EditorRef>(null);
+
+  const updateStateCheckbox = useCallback((event) => {
+    if (editorRef.current == null) { return }
+    const value = event.target.checked;
+    setIsMarkdown(value);
+    // changeMode
+    editorRef.current.setGfmMode(value);
+  }, []);
+
+  const renderHtml = useCallback((markdown: string) => {
+    const context = {
+      markdown,
+      parsedHTML: '',
+    };
+
+    const interceptorManager: IInterceptorManager = (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) });
+  }, [growiRenderer]);
+
+  const handleSelect = useCallback((activeTab: string) => {
+    setActiveTab(activeTab);
+    renderHtml(comment);
+  }, [comment, renderHtml]);
+
+  useEffect(() => {
+    if (slackChannels === undefined) { return }
+    setSlackChannels(slackChannelsData?.toString());
+  }, [slackChannelsData, slackChannels]);
+
+  const initializeEditor = useCallback(() => {
+    setComment('');
+    setIsMarkdown(true);
+    setHtml('');
+    setActiveTab('comment_editor');
+    setError(undefined);
+    // reset value
+    if (editorRef.current == null) { return }
+    editorRef.current.setValue('');
+  }, []);
+
+  const cancelButtonClickedHandler = useCallback(() => {
+    // change state to not ready
+    // when this editor is for the new comment mode
+    if (isForNewComment) {
+      setIsReadyToUse(false);
+    }
+
+    if (onCancelButtonClicked != null) {
+      onCancelButtonClicked();
+    }
+  }, [isForNewComment, onCancelButtonClicked]);
+
+  const postComment = useCallback(async() => {
+    try {
+      if (currentCommentId != null) {
+        await commentContainer.putComment(
+          comment,
+          isMarkdown,
+          currentCommentId,
+          commentCreator,
+        );
+      }
+      else {
+        await commentContainer.postComment(
+          comment,
+          isMarkdown,
+          replyTo,
+          isSlackEnabled,
+          slackChannels,
+        );
+      }
+      initializeEditor();
+
+      if (onCommentButtonClicked != null) {
+        onCommentButtonClicked();
+      }
+    }
+    catch (err) {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      setError(errorMessage);
+    }
+  }, [
+    comment, commentContainer, currentCommentId, commentCreator, initializeEditor,
+    isMarkdown, isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
+  ]);
+
+  const ctrlEnterHandler = useCallback((event) => {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    postComment();
+  }, [postComment]);
+
+  const apiErrorHandler = useCallback((error: Error) => {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }, []);
+
+  const uploadHandler = useCallback(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 ?? '');
+    try {
+      // TODO: typescriptize res
+      const res = await apiPostForm(endpoint, formData) as any;
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = `!${insertText}`;
+      }
+      editorRef.current.insertText(insertText);
+    }
+    catch (err) {
+      apiErrorHandler(err);
+    }
+    finally {
+      editorRef.current.terminateUploadingState();
+    }
+  }, [apiErrorHandler, currentPageId, currentPagePath]);
+
+  const getCommentHtml = useCallback(() => {
+    return (
+      <CommentPreview
+        html={html}
+      />
+    );
+  }, [html]);
+
+  const renderBeforeReady = useCallback((): JSX.Element => {
+    return (
+      <div className="text-center">
+        <NotAvailableForGuest>
+          <button
+            type="button"
+            className="btn btn-lg btn-link"
+            onClick={() => setIsReadyToUse(true)}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+        </NotAvailableForGuest>
+      </div>
+    );
+  }, []);
+
+  const renderReady = () => {
+
+    const commentPreview = isMarkdown ? getCommentHtml() : null;
+
+    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={cancelButtonClickedHandler}>
+        Cancel
+      </Button>
+    );
+    const submitButton = (
+      <Button
+        outline
+        color="primary"
+        className="btn btn-outline-primary rounded-pill"
+        onClick={postComment}
+      >
+        Comment
+      </Button>
+    );
+
+    // TODO: typescriptize Editor
+    const AnyEditor = Editor as any;
+
+    return (
+      <>
+        <div className="comment-write">
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
+          <TabContent activeTab={activeTab}>
+            <TabPane tabId="comment_editor">
+              <AnyEditor
+                ref={editorRef}
+                value={comment}
+                isGfmMode={isMarkdown}
+                lineNumbers={false}
+                isMobile={isMobile}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
+                onChange={setComment}
+                onUpload={uploadHandler}
+                onCtrlEnter={ctrlEnterHandler}
+                isComment
+              />
+              {/*
+                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
+                See a review comment in https://github.com/weseek/growi/pull/3473
+              */}
+            </TabPane>
+            <TabPane tabId="comment_preview">
+              <div className="comment-form-preview">
+                {commentPreview}
+              </div>
+            </TabPane>
+          </TabContent>
+        </div>
+
+        <div className="comment-submit">
+          <div className="d-flex">
+            <label className="mr-2">
+              {activeTab === 'comment_editor' && (
+                <span className="custom-control custom-checkbox">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="comment-form-is-markdown"
+                    name="isMarkdown"
+                    checked={isMarkdown}
+                    value="1"
+                    onChange={updateStateCheckbox}
+                  />
+                  <label
+                    className="ml-2 custom-control-label"
+                    htmlFor="comment-form-is-markdown"
+                  >
+                    Markdown
+                  </label>
+                </span>
+              ) }
+            </label>
+            <span className="flex-grow-1" />
+            <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
+
+            { isSlackConfigured
+              && (
+                <div className="form-inline align-self-center mr-md-2">
+                  <SlackNotification
+                    isSlackEnabled
+                    slackChannels={slackChannelsData?.toString() ?? ''}
+                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
+                    onChannelChange={setSlackChannels}
+                    id="idForComment"
+                  />
+                </div>
+              )
+            }
+            <div className="d-none d-sm-block">
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+          <div className="d-block d-sm-none mt-2">
+            <div className="d-flex justify-content-end">
+              { error && errorMessage }
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+        </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
+            ? renderReady()
+            : renderBeforeReady()
+          }
+        </div>
+      </div>
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
+  CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer],
+);
+
+export default CommentEditorWrapper;

+ 11 - 1
packages/app/src/interfaces/global.ts

@@ -1,3 +1,13 @@
+import EventEmitter from 'events';
+
 import Xss from '~/services/xss';
 
-export type CustomWindow = Window & typeof globalThis & { xss: Xss };
+import { IGraphViewer } from './graph-viewer';
+import { IInterceptorManager } from './interceptor-manager';
+
+export type CustomWindow = Window
+                         & typeof globalThis
+                         & { xss: Xss }
+                         & { interceptorManager: IInterceptorManager }
+                         & { globalEmitter: EventEmitter }
+                         & { GraphViewer: IGraphViewer };

+ 3 - 0
packages/app/src/interfaces/graph-viewer.ts

@@ -0,0 +1,3 @@
+export interface IGraphViewer {
+  createViewerForElement: (Element) => void,
+}

+ 15 - 0
packages/app/src/interfaces/interceptor-manager.ts

@@ -0,0 +1,15 @@
+interface BasicInterceptor {
+  getId: () => string,
+  isInterceptWhen: (contextName: string) => boolean,
+  isProcessableParallel: () => boolean,
+  process: (contextName: string, args: any) => Promise<any>
+}
+
+export interface IInterceptorManager {
+  interceptorAndOrders: {interceptor: BasicInterceptor, order: number}[],
+  interceptors: BasicInterceptor[],
+  addInterceptor: (inerceptor: BasicInterceptor, order: number) => void,
+  addInterceptors: (inerceptors: BasicInterceptor[], order: number) => void,
+  process: (contextName: string, args: any) => Promise<void>,
+  doProcess: (inerceptor: BasicInterceptor, contextName: string, args: any) => Promise<void>
+}