Yuki Takei 3 лет назад
Родитель
Сommit
635152b8ea

+ 1 - 2
packages/app/src/client/app.jsx

@@ -40,7 +40,6 @@ import TrashPageAlert from '../components/Page/TrashPageAlert';
 import PageComment from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
-import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import BookmarkList from '../components/PageList/BookmarkList';
 import PageStatusAlert from '../components/PageStatusAlert';
 import PageTimeline from '../components/PageTimeline';
@@ -66,7 +65,7 @@ const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
-const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
+const editorContainer = new EditorContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [

+ 15 - 54
packages/app/src/client/services/EditorContainer.js

@@ -6,13 +6,25 @@ import { apiv3Get } from '../util/apiv3-client';
 
 const logger = loggerFactory('growi:services:EditorContainer');
 
+
+const defaultEditorOptions = {
+  theme: 'elegant',
+  keymapMode: 'default',
+  styleActiveLine: false,
+};
+
+const defaultPreviewOptions = {
+  renderMathJaxInRealtime: false,
+  renderDrawioInRealtime: true,
+};
+
 /**
  * Service container related to options for Editor/Preview
  * @extends {Container} unstated Container
  */
 export default class EditorContainer extends Container {
 
-  constructor(appContainer, defaultEditorOptions, defaultPreviewOptions) {
+  constructor(appContainer) {
     super();
 
     this.appContainer = appContainer;
@@ -29,8 +41,8 @@ export default class EditorContainer extends Container {
     this.state = {
       tags: null,
 
-      editorOptions: {},
-      previewOptions: {},
+      editorOptions: defaultEditorOptions,
+      previewOptions: defaultPreviewOptions,
 
       // Defaults to null to show modal when not in DB
       // isTextlintEnabled: null,
@@ -43,9 +55,6 @@ export default class EditorContainer extends Container {
 
     this.initDrafts();
 
-    this.editorOptions = null;
-    this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
-    this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }
 
   /**
@@ -80,30 +89,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-  initEditorOptions(stateKey, localStorageKey, defaultOptions) {
-    // load from localStorage
-    const optsStr = window.localStorage[localStorageKey];
-
-    let loadedOpts = {};
-    // JSON.parseparse
-    if (optsStr != null) {
-      try {
-        loadedOpts = JSON.parse(optsStr);
-      }
-      catch (e) {
-        this.localStorage.removeItem(localStorageKey);
-      }
-    }
-
-    // set to state obj
-    this.state[stateKey] = Object.assign(defaultOptions, loadedOpts);
-  }
-
-  saveOptsToLocalStorage() {
-    window.localStorage.setItem('editorOptions', JSON.stringify(this.state.editorOptions));
-    window.localStorage.setItem('previewOptions', JSON.stringify(this.state.previewOptions));
-  }
-
   setCaretLine(line) {
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
@@ -177,28 +162,4 @@ export default class EditorContainer extends Container {
     return null;
   }
 
-
-  /**
-   * Retrieve Editor Settings
-   */
-  // async retrieveEditorSettings() {
-  //   if (this.appContainer.isGuestUser) {
-  //     return;
-  //   }
-
-  //   const { data } = await apiv3Get('/personal-setting/editor-settings');
-
-  //   if (data?.textlintSettings == null) {
-  //     return;
-  //   }
-
-  //   // Defaults to null to show modal when not in DB
-  //   const { isTextlintEnabled = null, textlintRules = [] } = data.textlintSettings;
-
-  //   this.setState({
-  //     isTextlintEnabled,
-  //     textlintRules,
-  //   });
-  // }
-
 }

+ 8 - 14
packages/app/src/components/PageEditor/DownloadDictModal.tsx

@@ -1,19 +1,19 @@
-import React, { useState, FC } from 'react';
-import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 type DownloadDictModalProps = {
   isModalOpen: boolean
-  onConfirmEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
+  onEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
   onCancel?: () => void;
 };
 
-export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
+export const DownloadDictModal = (props: DownloadDictModalProps): JSX.Element => {
   const { t } = useTranslation('');
-  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(true);
+  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(false);
 
   const onCancel = () => {
     if (props.onCancel != null) {
@@ -22,8 +22,8 @@ export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
   };
 
   const onConfirmEnableTextlint = () => {
-    if (props.onConfirmEnableTextlint != null) {
-      props.onConfirmEnableTextlint(isSkipAskingAgainChecked);
+    if (props.onEnableTextlint != null) {
+      props.onEnableTextlint(isSkipAskingAgainChecked);
     }
   };
 
@@ -67,9 +67,3 @@ export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
     </Modal>
   );
 };
-
-DownloadDictModal.propTypes = {
-  isModalOpen: PropTypes.bool.isRequired,
-  onConfirmEnableTextlint: PropTypes.func,
-  onCancel: PropTypes.func,
-};

+ 278 - 324
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -1,283 +1,186 @@
-import React from 'react';
+import React, {
+  memo, useCallback, useMemo, useState,
+} from 'react';
 
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { useEditorSettings, useIsTextlintEnabled } from '~/stores/editor';
 
+import { KeyMapMode } from '../../interfaces/editor-settings';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import { DownloadDictModal } from './DownloadDictModal';
 
 
-export const defaultEditorOptions = {
-  theme: 'elegant',
-  keymapMode: 'default',
-  styleActiveLine: false,
-};
-
-export const defaultPreviewOptions = {
-  renderMathJaxInRealtime: false,
-  renderDrawioInRealtime: true,
-};
-
-class OptionsSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.appContainer.getConfig();
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-
-    this.state = {
-      isCddMenuOpened: false,
-      isMathJaxEnabled,
-      isDownloadDictModalShown: false,
-      isSkipAskingAgainChecked: false,
-    };
-
-    this.availableThemes = [
-      'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
-    ];
-    this.keymapModes = {
-      default: 'Default',
-      vim: 'Vim',
-      emacs: 'Emacs',
-      sublime: 'Sublime Text',
-    };
-    this.typicalIndentSizes = [2, 4];
-
-    this.onChangeTheme = this.onChangeTheme.bind(this);
-    this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
-    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
-    this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
-    this.onClickRenderDrawioInRealtime = this.onClickRenderDrawioInRealtime.bind(this);
-    this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
-    this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
-    this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
-    this.toggleTextlint = this.toggleTextlint.bind(this);
-    this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
-    this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
-    this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
-  }
-
-  onChangeTheme(newValue) {
-    const { editorContainer } = this.props;
-
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { theme: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onChangeKeymapMode(newValue) {
-    const { editorContainer } = this.props;
-
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { keymapMode: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickStyleActiveLine(event) {
-    const { editorContainer } = this.props;
-
-    // keep dropdown opened
-    this._cddForceOpen = true;
-
-    const newValue = !editorContainer.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { styleActiveLine: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
-
-  onClickRenderMathJaxInRealtime(event) {
-    const { editorContainer } = this.props;
-
-    const newValue = !editorContainer.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderMathJaxInRealtime: newValue });
-    editorContainer.setState({ previewOptions: newOpts });
-
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
+const AVAILABLE_THEMES = [
+  'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
+];
 
-  onClickRenderDrawioInRealtime(event) {
-    const { editorContainer } = this.props;
+const TYPICAL_INDENT_SIZE = [2, 4];
 
-    const newValue = !editorContainer.state.previewOptions.renderDrawioInRealtime;
-    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderDrawioInRealtime: newValue });
-    editorContainer.setState({ previewOptions: newOpts });
 
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
+const ThemeSelector = (): JSX.Element => {
 
-  onClickMarkdownTableAutoFormatting(event) {
-    const { editorContainer } = this.props;
+  const { data: editorSettings, update } = useEditorSettings();
 
-    const newValue = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
-    const newOpts = Object.assign(editorContainer.state.editorOptions, { ignoreMarkdownTableAutoFormatting: newValue });
-    editorContainer.setState({ editorOptions: newOpts });
+  const menuItems = useMemo(() => (
+    <>
+      { AVAILABLE_THEMES.map((theme) => {
+        return <button key={theme} className="dropdown-item" type="button" onClick={() => update({ theme })}>{theme}</button>;
+      }) }
+    </>
+  ), [update]);
 
-    // save to localStorage
-    editorContainer.saveOptsToLocalStorage();
-  }
+  const selectedTheme = editorSettings?.theme ?? 'elegant';
 
-  async updateIsTextlintEnabledToDB(newVal) {
-    try {
-      await apiv3Put('/personal-setting/editor-settings', { textlintSettings: { isTextlintEnabled: newVal } });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-theme">Theme</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-theme"
+        >
+          {selectedTheme}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
+        </div>
+      </div>
+    </div>
+  );
+};
 
-  toggleTextlint() {
-    const { editorContainer } = this.props;
-    const newVal = !editorContainer.state.isTextlintEnabled;
-    editorContainer.setState({ isTextlintEnabled: newVal });
-    if (this.state.isSkipAskingAgainChecked) {
-      this.updateIsTextlintEnabledToDB(newVal);
-    }
-  }
 
-  switchTextlintEnabledHandler() {
-    const { editorContainer } = this.props;
-    if (editorContainer.state.isTextlintEnabled === null) {
-      this.setState({ isDownloadDictModalShown: true });
-      return;
-    }
-    this.toggleTextlint();
-  }
+type KeyMapModeToLabel = {
+  [key in KeyMapMode]: string;
+}
 
-  confirmEnableTextlintHandler(isSkipAskingAgainChecked) {
-    this.setState(
-      { isSkipAskingAgainChecked, isDownloadDictModalShown: false },
-      () => this.toggleTextlint(),
-    );
-  }
+const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
+  default: 'Default',
+  vim: 'Vim',
+  emacs: 'Emacs',
+  sublime: 'Sublime Text',
+};
 
-  onToggleConfigurationDropdown(newValue) {
-    this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
-  }
+const KeymapSelector = memo((): JSX.Element => {
+
+  const { data: editorSettings, update } = useEditorSettings();
+
+  Object.keys(KEYMAP_LABEL_MAP);
+  const menuItems = useMemo(() => (
+    <>
+      { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
+        const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
+        const icon = (keymapMode !== 'default')
+          ? <img src={`/images/icons/${keymapMode}.png`} width="16px" className="mr-2"></img>
+          : null;
+        return <button key={keymapMode} className="dropdown-item" type="button" onClick={() => update({ keymapMode })}>{icon}{keymapLabel}</button>;
+      }) }
+    </>
+  ), [update]);
+
+  const selectedKeymapMode = editorSettings?.keymapMode ?? 'default';
+
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-keymap">Keymap</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-keymap"
+        >
+          { editorSettings != null && selectedKeymapMode}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
+        </div>
+      </div>
+    </div>
+  );
 
-  onChangeIndentSize(newValue) {
-    const { editorContainer } = this.props;
-    editorContainer.setState({ indentSize: newValue });
-  }
+});
 
-  renderThemeSelector() {
-    const { editorContainer } = this.props;
 
-    const selectedTheme = editorContainer.state.editorOptions.theme;
-    const menuItems = this.availableThemes.map((theme) => {
-      return <button key={theme} className="dropdown-item" type="button" onClick={() => this.onChangeTheme(theme)}>{theme}</button>;
-    });
+type IndentSizeSelectorProps = {
+  isIndentSizeForced: boolean,
+  selectedIndentSize: number,
+  onChange: (indentSize: number) => void,
+}
 
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-theme">Theme</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-theme"
-          >
-            {selectedTheme}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
+const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onChange }: IndentSizeSelectorProps): JSX.Element => {
+  const menuItems = useMemo(() => (
+    <>
+      { TYPICAL_INDENT_SIZE.map((indent) => {
+        return <button key={indent} className="dropdown-item" type="button" onClick={() => onChange(indent)}>{indent}</button>;
+      }) }
+    </>
+  ), [onChange]);
+
+  return (
+    <div className="input-group flex-nowrap">
+      <div className="input-group-prepend">
+        <span className="input-group-text" id="igt-indent">Indent</span>
+      </div>
+      <div className="input-group-append dropup">
+        <button
+          type="button"
+          className="btn btn-outline-secondary dropdown-toggle"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+          aria-describedby="igt-indent"
+          disabled={isIndentSizeForced}
+        >
+          {selectedIndentSize}
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+          {menuItems}
         </div>
       </div>
-    );
-  }
+    </div>
+  );
 
-  renderKeymapModeSelector() {
-    const { editorContainer } = this.props;
+});
 
-    const selectedKeymapMode = editorContainer.state.editorOptions.keymapMode;
-    const menuItems = Object.keys(this.keymapModes).map((mode) => {
-      const label = this.keymapModes[mode];
-      const icon = (mode !== 'default')
-        ? <img src={`/images/icons/${mode}.png`} width="16px" className="mr-2"></img>
-        : null;
-      return <button key={mode} className="dropdown-item" type="button" onClick={() => this.onChangeKeymapMode(mode)}>{icon}{label}</button>;
-    });
 
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-keymap">Keymap</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-keymap"
-          >
-            {selectedKeymapMode}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
-        </div>
-      </div>
-    );
-  }
+type ConfigurationDropdownProps = {
+  isMathJaxEnabled: boolean,
+  onConfirmEnableTextlint?: () => void,
+}
 
-  renderConfigurationDropdown() {
-    return (
-      <div className="my-0 form-group">
+const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+  const { t } = useTranslation();
 
-        <Dropdown
-          direction="up"
-          className="grw-editor-configuration-dropdown"
-          isOpen={this.state.isCddMenuOpened}
-          toggle={this.onToggleConfigurationDropdown}
-        >
+  const [isCddMenuOpened, setCddMenuOpened] = useState(false);
 
-          <DropdownToggle color="outline-secondary" caret>
-            <i className="icon-settings"></i>
-          </DropdownToggle>
+  const { data: editorSettings, update } = useEditorSettings();
 
-          <DropdownMenu>
-            {this.renderActiveLineMenuItem()}
-            {this.renderRealtimeMathJaxMenuItem()}
-            {this.renderRealtimeDrawioMenuItem()}
-            {this.renderMarkdownTableAutoFormattingMenuItem()}
-            {this.renderIsTextlintEnabledMenuItem()}
-            {/* <DropdownItem divider /> */}
-          </DropdownMenu>
+  const { data: isTextlintEnabled, mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
 
-        </Dropdown>
-
-      </div>
-    );
-  }
+  const renderActiveLineMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
 
-  renderActiveLineMenuItem() {
-    const { t, editorContainer } = this.props;
-    const isActive = editorContainer.state.editorOptions.styleActiveLine;
+    const isActive = editorSettings.styleActiveLine;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -286,7 +189,7 @@ class OptionsSelector extends React.Component {
     const iconClassName = iconClasses.join(' ');
 
     return (
-      <DropdownItem toggle={false} onClick={this.onClickStyleActiveLine}>
+      <DropdownItem toggle={false} onClick={() => update({ styleActiveLine: !isActive })}>
         <div className="d-flex justify-content-between">
           <span className="icon-container"></span>
           <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
@@ -294,17 +197,18 @@ class OptionsSelector extends React.Component {
         </div>
       </DropdownItem>
     );
-  }
+  }, [editorSettings, update, t]);
 
-  renderRealtimeMathJaxMenuItem() {
-    if (!this.state.isMathJaxEnabled) {
-      return;
+  const renderRealtimeMathJaxMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
     }
 
-    const { editorContainer } = this.props;
+    if (!isMathJaxEnabled) {
+      return <></>;
+    }
 
-    const isEnabled = this.state.isMathJaxEnabled;
-    const isActive = isEnabled && editorContainer.state.previewOptions.renderMathJaxInRealtime;
+    const isActive = editorSettings.renderMathJaxInRealtime;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -313,7 +217,7 @@ class OptionsSelector extends React.Component {
     const iconClassName = iconClasses.join(' ');
 
     return (
-      <DropdownItem toggle={false} onClick={this.onClickRenderMathJaxInRealtime}>
+      <DropdownItem toggle={false} onClick={() => update({ renderMathJaxInRealtime: !isActive })}>
         <div className="d-flex justify-content-between">
           <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
           <span className="menuitem-label">MathJax Rendering</span>
@@ -321,12 +225,14 @@ class OptionsSelector extends React.Component {
         </div>
       </DropdownItem>
     );
-  }
+  }, [editorSettings, isMathJaxEnabled, update]);
 
-  renderRealtimeDrawioMenuItem() {
-    const { editorContainer } = this.props;
+  const renderRealtimeDrawioMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
 
-    const isActive = editorContainer.state.previewOptions.renderDrawioInRealtime;
+    const isActive = editorSettings.renderDrawioInRealtime;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -335,7 +241,7 @@ class OptionsSelector extends React.Component {
     const iconClassName = iconClasses.join(' ');
 
     return (
-      <DropdownItem toggle={false} onClick={this.onClickRenderDrawioInRealtime}>
+      <DropdownItem toggle={false} onClick={() => update({ renderDrawioInRealtime: !isActive })}>
         <div className="d-flex justify-content-between">
           <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
           <span className="menuitem-label">draw.io Rendering</span>
@@ -343,12 +249,14 @@ class OptionsSelector extends React.Component {
         </div>
       </DropdownItem>
     );
-  }
+  }, [editorSettings, update]);
 
-  renderMarkdownTableAutoFormattingMenuItem() {
-    const { t, editorContainer } = this.props;
-    // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).
-    const isActive = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
+  const renderMarkdownTableAutoFormattingMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const isActive = editorSettings.autoFormatMarkdownTable;
 
     const iconClasses = ['text-info'];
     if (isActive) {
@@ -357,7 +265,7 @@ class OptionsSelector extends React.Component {
     const iconClassName = iconClasses.join(' ');
 
     return (
-      <DropdownItem toggle={false} onClick={this.onClickMarkdownTableAutoFormatting}>
+      <DropdownItem toggle={false} onClick={() => update({ autoFormatMarkdownTable: !isActive })}>
         <div className="d-flex justify-content-between">
           <span className="icon-container"></span>
           <span className="menuitem-label">{ t('page_edit.auto_format_table') }</span>
@@ -365,19 +273,37 @@ class OptionsSelector extends React.Component {
         </div>
       </DropdownItem>
     );
-  }
+  }, [editorSettings, t, update]);
 
-  renderIsTextlintEnabledMenuItem() {
-    const isActive = this.props.editorContainer.state.isTextlintEnabled;
+  const renderIsTextlintEnabledMenuItem = useCallback(() => {
+    if (editorSettings == null) {
+      return <></>;
+    }
+
+    const clickHandler = () => {
+      if (isTextlintEnabled) {
+        mutateTextlintEnabled(false);
+        return;
+      }
+
+      if (editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles) {
+        mutateTextlintEnabled(true);
+        return;
+      }
+
+      if (onConfirmEnableTextlint != null) {
+        onConfirmEnableTextlint();
+      }
+    };
 
     const iconClasses = ['text-info'];
-    if (isActive) {
+    if (isTextlintEnabled) {
       iconClasses.push('ti-check');
     }
     const iconClassName = iconClasses.join(' ');
 
     return (
-      <DropdownItem toggle={false} onClick={this.switchTextlintEnabledHandler}>
+      <DropdownItem toggle={false} onClick={clickHandler}>
         <div className="d-flex justify-content-between">
           <span className="icon-container"></span>
           <span className="menuitem-label">Textlint</span>
@@ -385,71 +311,99 @@ class OptionsSelector extends React.Component {
         </div>
       </DropdownItem>
     );
-  }
+  }, [editorSettings, isTextlintEnabled, mutateTextlintEnabled, onConfirmEnableTextlint]);
+
+  return (
+    <div className="my-0 form-group">
+      <Dropdown
+        direction="up"
+        className="grw-editor-configuration-dropdown"
+        isOpen={isCddMenuOpened}
+        toggle={() => setCddMenuOpened(!isCddMenuOpened)}
+      >
+
+        <DropdownToggle color="outline-secondary" caret>
+          <i className="icon-settings"></i>
+        </DropdownToggle>
+
+        <DropdownMenu>
+          {renderActiveLineMenuItem()}
+          {renderRealtimeMathJaxMenuItem()}
+          {renderRealtimeDrawioMenuItem()}
+          {renderMarkdownTableAutoFormattingMenuItem()}
+          {renderIsTextlintEnabledMenuItem()}
+          {/* <DropdownItem divider /> */}
+        </DropdownMenu>
+
+      </Dropdown>
+    </div>
+  );
+
+});
+
+
+type Props = {
+  appContainer: AppContainer
+  editorContainer: EditorContainer,
+};
 
-  renderIndentSizeSelector() {
-    const { appContainer, editorContainer } = this.props;
-    const menuItems = this.typicalIndentSizes.map((indent) => {
-      return <button key={indent} className="dropdown-item" type="button" onClick={() => this.onChangeIndentSize(indent)}>{indent}</button>;
-    });
-    return (
-      <div className="input-group flex-nowrap">
-        <div className="input-group-prepend">
-          <span className="input-group-text" id="igt-indent">Indent</span>
-        </div>
-        <div className="input-group-append dropup">
-          <button
-            type="button"
-            className="btn btn-outline-secondary dropdown-toggle"
-            data-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="false"
-            aria-describedby="igt-indent"
-            disabled={appContainer.config.isIndentSizeForced}
-          >
-            {editorContainer.state.indentSize}
-          </button>
-          <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
-            {menuItems}
-          </div>
-        </div>
-      </div>
-    );
-  }
+const OptionsSelector = (props: Props): JSX.Element => {
+  const { appContainer, editorContainer } = props;
+  const config = appContainer.getConfig();
 
-  render() {
-    return (
-      <>
-        <div className="d-flex flex-row">
-          <span>{this.renderThemeSelector()}</span>
-          <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
-          <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
-          <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
-        </div>
+  const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
 
-        {!this.state.isSkipAskingAgainChecked && (
-          <DownloadDictModal
-            isModalOpen={this.state.isDownloadDictModalShown}
-            onConfirmEnableTextlint={this.confirmEnableTextlintHandler}
-            onCancel={() => this.setState({ isDownloadDictModalShown: false })}
-          />
-        )}
-      </>
-    );
+  const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
+  const { mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
+
+  if (editorSettings == null) {
+    return <></>;
   }
 
-}
+  return (
+    <>
+      <div className="d-flex flex-row">
+        <span>
+          <ThemeSelector />
+        </span>
+        <span className="d-none d-sm-block ml-2 ml-sm-4">
+          <KeymapSelector />
+        </span>
+        <span className="ml-2 ml-sm-4">
+          <IndentSizeSelector
+            isIndentSizeForced={config.isIndentSizeForced}
+            selectedIndentSize={editorContainer.state.indentSize}
+            onChange={newIndentSize => editorContainer.setState({ indentSize: newIndentSize })}
+          />
+        </span>
+        <span className="ml-2 ml-sm-4">
+          <ConfigurationDropdown
+            isMathJaxEnabled={!!config.env.MATHJAX}
+            onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
+          />
+        </span>
+      </div>
 
-/**
- * Wrapper component for using unstated
- */
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer, EditorContainer]);
+      { editorSettings != null && !editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles && (
+        <DownloadDictModal
+          isModalOpen={isDownloadDictModalShown}
+          onEnableTextlint={(isSkipAskingAgainChecked) => {
+            mutateTextlintEnabled(true);
 
-OptionsSelector.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
+            if (isSkipAskingAgainChecked) {
+              turnOffAskingBeforeDownloadLargeFiles();
+            }
+
+            setDownloadDictModalShown(false);
+          }}
+          onCancel={() => setDownloadDictModalShown(false)}
+        />
+      )}
+    </>
+  );
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withTranslation()(OptionsSelectorWrapper);
+
+const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer, EditorContainer]);
+export default OptionsSelectorWrapper;

+ 12 - 2
packages/app/src/interfaces/editor-settings.ts

@@ -9,11 +9,21 @@ export interface ITextlintSettings {
   textlintRules: ILintRule[];
 }
 
+const KeyMapMode = {
+  default: 'default',
+  vim: 'vim',
+  emacs: 'emacs',
+  sublime: 'sublime',
+} as const;
+
+export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];
+
 export interface IEditorSettings {
   theme: undefined | string,
-  keymapMode: undefined | 'vim' | 'emacs' | 'sublime',
+  keymapMode: undefined | KeyMapMode,
   styleActiveLine: boolean,
   renderMathJaxInRealtime: boolean,
   renderDrawioInRealtime: boolean,
-  textlintSettings: ITextlintSettings;
+  autoFormatMarkdownTable: boolean,
+  textlintSettings: undefined | ITextlintSettings;
 }

+ 1 - 0
packages/app/src/server/models/editor-settings.ts

@@ -27,6 +27,7 @@ const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsMo
   styleActiveLine: { type: Boolean, default: false },
   renderMathJaxInRealtime: { type: Boolean, default: true },
   renderDrawioInRealtime: { type: Boolean, default: true },
+  autoFormatMarkdownTable: { type: Boolean, default: true },
   textlintSettings: textlintSettingsSchema,
 });
 

+ 8 - 7
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -112,7 +112,8 @@ module.exports = (crowi) => {
       body('styleActiveLine').optional().isBoolean(),
       body('renderMathJaxInRealtime').optional().isBoolean(),
       body('renderDrawioInRealtime').optional().isBoolean(),
-      body('textlintSettings.isTextlintEnabled').optional().isBoolean(),
+      body('autoFormatMarkdownTable').optional().isBoolean(),
+      body('textlintSettings.neverAskBeforeDownloadLargeFiles').optional().isBoolean(),
       body('textlintSettings.textlintRules.*.name').optional().isString(),
       body('textlintSettings.textlintRules.*.options').optional(),
       body('textlintSettings.textlintRules.*.isEnabled').optional().isBoolean(),
@@ -508,17 +509,17 @@ module.exports = (crowi) => {
     const { body } = req;
 
     const {
-      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime,
+      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime, autoFormatMarkdownTable,
       textlintSettings,
     } = body;
 
     const document = {
-      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime,
+      theme, keymapMode, styleActiveLine, renderMathJaxInRealtime, renderDrawioInRealtime, autoFormatMarkdownTable,
     };
 
     if (textlintSettings != null) {
-      if (textlintSettings.isTextlintEnabled != null) {
-        Object.assign(document, { 'textlintSettings.isTextlintEnabled': textlintSettings.isTextlintEnabled });
+      if (textlintSettings.neverAskBeforeDownloadLargeFiles != null) {
+        Object.assign(document, { 'textlintSettings.neverAskBeforeDownloadLargeFiles': textlintSettings.neverAskBeforeDownloadLargeFiles });
       }
       if (textlintSettings.textlintRules != null) {
         Object.assign(document, { 'textlintSettings.textlintRules': textlintSettings.textlintRules });
@@ -562,8 +563,8 @@ module.exports = (crowi) => {
   router.get('/editor-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     try {
       const query = { userId: req.user.id };
-      const response = await EditorSettings.findOne(query);
-      return res.apiv3(response);
+      const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
+      return res.apiv3(editorSettings);
     }
     catch (err) {
       logger.error(err);

+ 37 - 3
packages/app/src/stores/editor.tsx

@@ -1,7 +1,7 @@
 import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { IEditorSettings } from '~/interfaces/editor-settings';
 
 import { useIsGuestUser } from './context';
@@ -11,13 +11,47 @@ export const useIsSlackEnabled = (isEnabled?: boolean): SWRResponse<boolean, Err
   return useStaticSWR('isSlackEnabled', isEnabled, { fallbackData: false });
 };
 
-export const useEditorSettings = (): SWRResponse<IEditorSettings, Error> => {
+
+type EditorSettingsOperation = {
+  update: (updateData: Partial<IEditorSettings>) => void,
+  turnOffAskingBeforeDownloadLargeFiles: () => void,
+}
+
+export const useEditorSettings = (): SWRResponse<IEditorSettings, Error> & EditorSettingsOperation => {
   const { data: isGuestUser } = useIsGuestUser();
 
-  return useSWRImmutable(
+  const swrResult = useSWRImmutable<IEditorSettings>(
     isGuestUser ? null : '/personal-setting/editor-settings',
     endpoint => apiv3Get(endpoint).then(result => result.data),
   );
+
+  return {
+    ...swrResult,
+    update: (updateData) => {
+      const { data, mutate } = swrResult;
+
+      if (data == null) {
+        return;
+      }
+
+      mutate({ ...data, ...updateData }, false);
+
+      // invoke API
+      apiv3Put('/personal-setting/editor-settings', updateData);
+    },
+    turnOffAskingBeforeDownloadLargeFiles: async() => {
+      const { data, mutate } = swrResult;
+
+      if (data == null) {
+        return;
+      }
+
+      // invoke API
+      await apiv3Put('/personal-setting/editor-settings', { textlintSettings: { neverAskBeforeDownloadLargeFiles: true } });
+      // revalidate
+      mutate();
+    },
+  };
 };
 
 export const useIsTextlintEnabled = (): SWRResponse<boolean, Error> => {