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

Merge pull request #6887 from weseek/feat/refactor-HandsontableModal-TSFC

feat: HandsonTableModal TSFC hooks
Yuki Takei 3 лет назад
Родитель
Сommit
83a4a5d644

+ 6 - 5
packages/app/src/components/Page.tsx

@@ -28,6 +28,7 @@ import loggerFactory from '~/utils/logger';
 import RevisionRenderer from './Page/RevisionRenderer';
 import { DrawioModal } from './PageEditor/DrawioModal';
 // import MarkdownTable from '~/client/models/MarkdownTable';
+// import type { HandsontableModalProps } from './PageEditor/HandsontableModal';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 
@@ -36,7 +37,7 @@ declare const globalEmitter: EventEmitter;
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
+// const HandsontableModal = dynamic<HandsontableModalProps>(() => import('./PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 const logger = loggerFactory('growi:Page');
@@ -58,7 +59,7 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
   linkEditModal: any;
 
-  handsontableModal: any;
+  // handsontableModal: any;
 
   drawioModal: any;
 
@@ -72,10 +73,10 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
+    // this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+    // this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
 
@@ -188,7 +189,7 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
           <>
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
-            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
+            {/* <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} /> */}
             {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
             {/* <DrawioModal
               ref={this.drawioModal}

+ 16 - 11
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -11,7 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useDrawioModal } from '~/stores/modal';
+import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -25,7 +25,7 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-import HandsontableModal from './HandsontableModal';
+// import { HandsontableModal } from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
@@ -116,7 +116,7 @@ class CodeMirrorEditor extends AbstractEditor {
     this.cm = React.createRef();
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
+    // this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
     this.init();
@@ -156,7 +156,7 @@ class CodeMirrorEditor extends AbstractEditor {
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
-    this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
+    // this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
@@ -869,9 +869,9 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
-  showHandsonTableHandler() {
-    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
-  }
+  // showHandsonTableHandler() {
+  //   this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
+  // }
 
 
   // fold draw.io section (::: drawio ~ :::)
@@ -1016,7 +1016,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Table"
-        onClick={this.showHandsonTableHandler}
+        onClick={() => this.props.onClickTableBtn(mtu.getMarkdownTable(this.getCodeMirror()))}
       >
         <EditorIcon icon="Table" />
       </Button>,
@@ -1135,11 +1135,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        <HandsontableModal
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        />
+        /> */}
       </div>
     );
   }
@@ -1161,12 +1161,17 @@ CodeMirrorEditor.defaultProps = {
 
 const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
+  const { open: openHandsontableModal } = useHandsontableModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile) => {
     openDrawioModal(drawioMxFile);
   }, [openDrawioModal]);
 
-  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+  const openTableModalHandler = useCallback((table) => {
+    openHandsontableModal(table);
+  }, [openHandsontableModal]);
+
+  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} onClickTableBtn={openTableModalHandler} {...props} />;
 });
 
 CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';

+ 0 - 537
packages/app/src/components/PageEditor/HandsontableModal.jsx

@@ -1,537 +0,0 @@
-import React from 'react';
-
-import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
-import PropTypes from 'prop-types';
-import {
-  Collapse,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import { debounce } from 'throttle-debounce';
-
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-import ExpandOrContractButton from '../ExpandOrContractButton';
-
-import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
-
-import styles from './HandsontableModal.module.scss';
-import 'handsontable/dist/handsontable.full.min.css';
-
-const DEFAULT_HOT_HEIGHT = 300;
-const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
-  r: 'htRight',
-  c: 'htCenter',
-  l: 'htLeft',
-  '': '',
-};
-
-export default class HandsontableModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    /*
-     * ## Note ##
-     * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
-     * However, changes made by the following operations are not synchronized.
-     *
-     * 1. move columns: Alignment changes are synchronized but data changes are not.
-     * 2. move rows: Data changes are not synchronized.
-     * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
-     * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
-     *
-     * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
-     */
-    this.state = {
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-      markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
-      markdownTable: HandsontableModal.getDefaultMarkdownTable(),
-      handsontableHeight: DEFAULT_HOT_HEIGHT,
-    };
-
-    this.init = this.init.bind(this);
-    this.reset = this.reset.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.save = this.save.bind(this);
-    this.afterLoadDataHandler = this.afterLoadDataHandler.bind(this);
-    this.beforeColumnResizeHandler = this.beforeColumnResizeHandler.bind(this);
-    this.afterColumnResizeHandler = this.afterColumnResizeHandler.bind(this);
-    this.modifyColWidthHandler = this.modifyColWidthHandler.bind(this);
-    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
-    this.afterColumnMoveHandler = this.afterColumnMoveHandler.bind(this);
-    this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
-    this.alignButtonHandler = this.alignButtonHandler.bind(this);
-    this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
-    this.importData = this.importData.bind(this);
-    this.expandWindow = this.expandWindow.bind(this);
-    this.contractWindow = this.contractWindow.bind(this);
-
-    // create debounced method for expanding HotTable
-    this.expandHotTableHeightWithDebounce = debounce(100, this.expandHotTableHeight);
-
-    // a Set instance that stores column indices which are resized manually.
-    // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
-    this.manuallyResizedColumnIndicesSet = new Set();
-
-    // generate setting object for HotTable instance
-    this.handsontableSettings = Object.assign(HandsontableModal.getDefaultHandsontableSetting(), {
-      contextMenu: this.createCustomizedContextMenu(),
-    });
-  }
-
-  init(markdownTable) {
-    const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
-    this.setState(
-      {
-        markdownTableOnInit: initMarkdownTable,
-        markdownTable: initMarkdownTable.clone(),
-      },
-    );
-
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  createCustomizedContextMenu() {
-    return {
-      items: {
-        row_above: {},
-        row_below: {},
-        col_left: {},
-        col_right: {},
-        separator1: Handsontable.plugins.ContextMenu.SEPARATOR,
-        remove_row: {},
-        remove_col: {},
-        separator2: Handsontable.plugins.ContextMenu.SEPARATOR,
-        custom_alignment: {
-          name: 'Align columns',
-          key: 'align_columns',
-          submenu: {
-            items: [
-              {
-                name: 'Left',
-                key: 'align_columns:1',
-                callback: (key, selection) => { this.align('l', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Center',
-                key: 'align_columns:2',
-                callback: (key, selection) => { this.align('c', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Right',
-                key: 'align_columns:3',
-                callback: (key, selection) => { this.align('r', selection[0].start.col, selection[0].end.col) },
-              },
-            ],
-          },
-        },
-      },
-    };
-  }
-
-  show(markdownTable) {
-    this.init(markdownTable);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-    });
-  }
-
-  /**
-   * Reset table data to initial value
-   *
-   * ## Note ##
-   * It may not return completely to the initial state because of the manualColumnMove operations.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  reset() {
-    this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  save() {
-    const markdownTable = new MarkdownTable(
-      this.hotTable.hotInstance.getData(),
-      this.markdownTableOption,
-    ).normalizeCells();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(markdownTable);
-    }
-
-    this.hide();
-  }
-
-  /**
-   * An afterLoadData hook
-   *
-   * This performs the following operations.
-   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
-   * - synchronize the handsontable alignment to the markdowntable alignment
-   *
-   * ## Note ##
-   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
-   *
-   * - markdownTable
-   * - handsontableHeight
-   *
-   * In detail, when the setState method is called with those state passed,
-   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
-   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
-   * In updateSettings method, the loadData method is called in some case.
-   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
-   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
-   * After the loadData method is executed, afterLoadData hooks are called.
-   */
-  afterLoadDataHandler(initialLoad) {
-    if (initialLoad) {
-      this.manuallyResizedColumnIndicesSet.clear();
-    }
-
-    this.synchronizeAlignment();
-  }
-
-  beforeColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
-  }
-
-  afterColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    this.manuallyResizedColumnIndicesSet.add(currentColumn);
-    // force re-render
-    const hotInstance = this.hotTable.hotInstance;
-    hotInstance.render();
-  }
-
-  modifyColWidthHandler(width, column) {
-    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
-    if (this.manuallyResizedColumnIndicesSet.has(column)) {
-      return width;
-    }
-    // return fixed width if first initializing
-    return Math.max(80, Math.min(400, width));
-  }
-
-  beforeColumnMoveHandler(columns, target) {
-    // clear 'manuallyResizedColumnIndicesSet'
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  /**
-   * An afterColumnMove hook.
-   *
-   * This synchronizes alignment when columns are moved by manualColumnMove
-   */
-  afterColumnMoveHandler(columns, target) {
-    const align = [].concat(this.state.markdownTable.options.align);
-    const removed = align.splice(columns[0], columns.length);
-
-    /*
-     * The following is a description of the algorithm for the alignment synchronization.
-     *
-     * Consider the case where the target is X and the columns are [2,3] and data is as follows.
-     *
-     * 0 1 2 3 4 5 (insert position number)
-     * +-+-+-+-+-+
-     * | | | | | |
-     * +-+-+-+-+-+
-     *  0 1 2 3 4  (column index number)
-     *
-     * At first, remove columns by the splice.
-     *
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *  0 1     4
-     *
-     * Next, insert those columns into a new position.
-     * However the target number is a insert position number before deletion, it may be changed.
-     * These are changed as follows.
-     *
-     * Before:
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * After:
-     * 0 1 2   2 3
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
-     * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
-     *
-     */
-    let insertPosition = 0;
-    if (target <= columns[0]) {
-      insertPosition = target;
-    }
-    else if (columns[columns.length - 1] < target) {
-      insertPosition = target - columns.length;
-    }
-    align.splice(...[insertPosition, 0].concat(removed));
-
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align });
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * change the markdownTable alignment and synchronize the handsontable alignment to it
-   */
-  align(direction, startCol, endCol) {
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align: [].concat(prevState.markdownTable.options.align) });
-      for (let i = startCol; i <= endCol; i++) {
-        newMarkdownTable.options.align[i] = direction;
-      }
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * synchronize the handsontable alignment to the markdowntable alignment
-   */
-  synchronizeAlignment() {
-    if (this.hotTable == null) {
-      return;
-    }
-
-    const align = this.state.markdownTable.options.align;
-    const hotInstance = this.hotTable.hotInstance;
-
-    if (hotInstance.isDestroyed === true) {
-      return;
-    }
-
-    for (let i = 0; i < align.length; i++) {
-      for (let j = 0; j < hotInstance.countRows(); j++) {
-        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
-      }
-    }
-    hotInstance.render();
-  }
-
-  alignButtonHandler(direction) {
-    const selectedRange = this.hotTable.hotInstance.getSelectedRange();
-    if (selectedRange == null) return;
-
-    let startCol;
-    let endCol;
-
-    if (selectedRange[0].from.col < selectedRange[0].to.col) {
-      startCol = selectedRange[0].from.col;
-      endCol = selectedRange[0].to.col;
-    }
-    else {
-      startCol = selectedRange[0].to.col;
-      endCol = selectedRange[0].from.col;
-    }
-
-    this.align(direction, startCol, endCol);
-  }
-
-  toggleDataImportArea() {
-    this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
-  }
-
-  /**
-   * Import a markdowntable
-   *
-   * ## Note ##
-   * The manualColumnMove operation affects the column order of imported data.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  importData(markdownTable) {
-    this.init(markdownTable);
-    this.toggleDataImportArea();
-  }
-
-  expandWindow() {
-    this.setState({ isWindowExpanded: true });
-
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    this.expandHotTableHeightWithDebounce();
-  }
-
-  contractWindow() {
-    this.setState({ isWindowExpanded: false, handsontableHeight: DEFAULT_HOT_HEIGHT });
-  }
-
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  expandHotTableHeight() {
-    if (this.state.isWindowExpanded && this.hotTableContainer != null) {
-      const height = this.hotTableContainer.getBoundingClientRect().height;
-      this.setState({ handsontableHeight: height });
-    }
-  }
-
-  get markdownTableOption() {
-    return {
-      align: [].concat(this.state.markdownTable.options.align),
-      pad: this.props.autoFormatMarkdownTable !== false,
-    };
-  }
-
-  renderCloseButton() {
-    return (
-      <button type="button" className="close" onClick={this.cancel} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    );
-  }
-
-  render() {
-
-    const buttons = (
-      <span>
-        {/* change order because of `float: right` by '.close' class */}
-        {this.renderCloseButton()}
-        <ExpandOrContractButton
-          isWindowExpanded={this.state.isWindowExpanded}
-          contractWindow={this.contractWindow}
-          expandWindow={this.expandWindow}
-        />
-      </span>
-    );
-
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        keyboard={false}
-        size="lg"
-        className={`handsontable-modal ${styles['grw-handsontable']}
-          ${this.state.isWindowExpanded && `grw-modal-expanded ${styles['grw-modal-expanded']}`}`}
-      >
-        <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
-          Edit Table
-        </ModalHeader>
-        <ModalBody className="p-0 d-flex flex-column">
-          <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
-            <button
-              type="button"
-              className="mr-4 data-import-button btn btn-secondary"
-              data-toggle="collapse"
-              data-target="#collapseDataImport"
-              aria-expanded={this.state.isDataImportAreaExpanded}
-              onClick={this.toggleDataImportArea}
-            >
-              <span className="mr-3">Data Import</span><i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
-            </button>
-            <div role="group" className="btn-group">
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('l') }}>
-                <i className="ti ti-align-left"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('c') }}>
-                <i className="ti ti-align-center"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('r') }}>
-                <i className="ti ti-align-right"></i>
-              </button>
-            </div>
-            <Collapse isOpen={this.state.isDataImportAreaExpanded}>
-              <div className="mt-4">
-                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData} />
-              </div>
-            </Collapse>
-          </div>
-          <div ref={(c) => { this.hotTableContainer = c }} className="m-4 hot-table-container">
-            <HotTable
-              ref={(c) => { this.hotTable = c }}
-              data={this.state.markdownTable.table}
-              settings={this.handsontableSettings}
-              height={this.state.handsontableHeight}
-              afterLoadData={this.afterLoadDataHandler}
-              modifyColWidth={this.modifyColWidthHandler}
-              beforeColumnMove={this.beforeColumnMoveHandler}
-              beforeColumnResize={this.beforeColumnResizeHandler}
-              afterColumnResize={this.afterColumnResizeHandler}
-              afterColumnMove={this.afterColumnMoveHandler}
-            />
-          </div>
-        </ModalBody>
-        <ModalFooter className="grw-modal-footer">
-          <button type="button" className="btn btn-danger" onClick={this.reset}>Reset</button>
-          <div className="ml-auto">
-            <button type="button" className="mr-2 btn btn-secondary" onClick={this.cancel}>Cancel</button>
-            <button type="button" className="btn btn-primary" onClick={this.save}>Done</button>
-          </div>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-  static getDefaultMarkdownTable() {
-    return new MarkdownTable(
-      [
-        ['col1', 'col2', 'col3'],
-        ['', '', ''],
-        ['', '', ''],
-      ],
-      {
-        align: ['', '', ''],
-      },
-    );
-  }
-
-  static getDefaultHandsontableSetting() {
-    return {
-      rowHeaders: true,
-      colHeaders: true,
-      manualRowMove: true,
-      manualRowResize: true,
-      manualColumnMove: true,
-      manualColumnResize: true,
-      selectionMode: 'multiple',
-      outsideClickDeselects: false,
-    };
-  }
-
-}
-
-HandsontableModal.propTypes = {
-  onSave: PropTypes.func,
-  autoFormatMarkdownTable: PropTypes.bool,
-};

+ 27 - 5
packages/app/src/components/PageEditor/HandsontableModal.module.scss

@@ -14,11 +14,33 @@
       text-align: inherit;
     }
   }
-}
 
-// expand .hot-table-container (with flexbox)
-.grw-modal-expanded :global {
-  .hot-table-container {
-    flex: 1;
+  // expand .hot-table-container (with flexbox)
+  .grw-modal-expanded {
+    .hot-table-container {
+      flex: 1;
+    }
+  }
+
+
+  // Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
+  // see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
+  // This issue fixing from Handsontable v 7.0.0
+  // see: https://github.com/handsontable/handsontable/issues/2937#issuecomment-480824024
+  .modal.in .modal-dialog.handsontable-modal {
+    transform: none;
+
+    .data-import-button {
+      position: relative;
+      padding-right: 35px;
+      padding-left: 10px;
+
+      i:before {
+        position: absolute;
+        top: 6px;
+        right: 8px;
+        font-size: 20px;
+      }
+    }
   }
 }

+ 521 - 0
packages/app/src/components/PageEditor/HandsontableModal.tsx

@@ -0,0 +1,521 @@
+import React, { useState } from 'react';
+
+import { HotTable } from '@handsontable/react';
+import Handsontable from 'handsontable';
+import {
+  Collapse,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { debounce } from 'throttle-debounce';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+import { useHandsontableModal } from '~/stores/modal';
+
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
+
+import styles from './HandsontableModal.module.scss';
+import 'handsontable/dist/handsontable.full.min.css';
+
+const DEFAULT_HOT_HEIGHT = 300;
+const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
+  r: 'htRight',
+  c: 'htCenter',
+  l: 'htLeft',
+  '': '',
+};
+
+export type HandsontableModalProps = {
+  // ref: any,
+  // onSave: (markdownTable: MarkdownTable) => Promise<void>,
+  // autoFormatMarkdownTable: boolean,
+}
+
+export const HandsontableModal = (props: HandsontableModalProps): JSX.Element => {
+
+  const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+  const isOpened = handsontableModalData?.isOpened ?? false;
+
+  // const { onSave, autoFormatMarkdownTable } = props;
+
+  const defaultMarkdownTable = () => {
+    return new MarkdownTable(
+      [
+        ['col1', 'col2', 'col3'],
+        ['', '', ''],
+        ['', '', ''],
+      ],
+      {
+        align: ['', '', ''],
+      },
+    );
+  };
+
+  const defaultHandsontableSetting = () => {
+    return {
+      rowHeaders: true,
+      colHeaders: true,
+      manualRowMove: true,
+      manualRowResize: true,
+      manualColumnMove: true,
+      manualColumnResize: true,
+      selectionMode: 'multiple',
+      outsideClickDeselects: false,
+    };
+  };
+
+  // a Set instance that stores column indices which are resized manually.
+  // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
+  const manuallyResizedColumnIndicesSet = new Set();
+
+  /*
+   * ## Note ##
+   * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
+   * However, changes made by the following operations are not synchronized.
+   *
+   * 1. move columns: Alignment changes are synchronized but data changes are not.
+   * 2. move rows: Data changes are not synchronized.
+   * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
+   * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
+   *
+   * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
+   */
+  const [hotTable, setHotTable] = useState<HotTable | null>();
+  const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
+  // const [isShow, setIsShow] = useState<boolean>(false);
+  const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState<boolean>(false);
+  const [isWindowExpanded, setIsWindowExpanded] = useState<boolean>(false);
+  const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(() => defaultMarkdownTable());
+  const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(() => defaultMarkdownTable());
+  const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+
+  const init = (markdownTable: MarkdownTable) => {
+    const initMarkdownTable = markdownTable || defaultMarkdownTable();
+    setMarkdownTableOnInit(initMarkdownTable);
+    setMarkdownTable(initMarkdownTable.clone());
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * Reset table data to initial value
+   *
+   * ## Note ##
+   * It may not return completely to the initial state because of the manualColumnMove operations.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const reset = () => {
+    setMarkdownTable(markdownTableOnInit.clone());
+  };
+
+  const cancel = () => {
+    closeHandsontableModal();
+    // hide()
+    setIsDataImportAreaExpanded(false);
+    setIsWindowExpanded(false);
+  };
+
+  // const show = () => {
+  //   init(markdownTable);
+  //   setIsShow(true);
+  // };
+
+  // const hide = () => {
+  //   setIsShow(false);
+  //   setIsDataImportAreaExpanded(false);
+  //   setIsWindowExpanded(false);
+  // };
+
+  const save = () => {
+    if (hotTable == null) {
+      return;
+    }
+
+    // const markdownTableOption = () => {
+    //   return {
+    //     align: [].concat(markdownTable.options.align),
+    //     pad: autoFormatMarkdownTable !== false,
+    //   };
+    // };
+
+    // const markdownTable = new MarkdownTable(
+    //   hotTable.hotInstance.getData(),
+    //   markdownTableOption,
+    // ).normalizeCells();
+
+    // if (onSave != null) {
+    //   onSave(markdownTable);
+    // }
+
+    cancel();
+  };
+
+  const beforeColumnResizeHandler = (currentColumn) => {
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
+  };
+
+  const afterColumnResizeHandler = (currentColumn) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    /* c
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    manuallyResizedColumnIndicesSet.add(currentColumn);
+    // force re-render
+    const hotInstance = hotTable.hotInstance;
+    hotInstance.render();
+  };
+
+  const modifyColWidthHandler = (width, column) => {
+    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
+    if (manuallyResizedColumnIndicesSet.has(column)) {
+      return width;
+    }
+    // return fixed width if first initializing
+    return Math.max(80, Math.min(400, width));
+  };
+
+  const beforeColumnMoveHandler = (columns, target) => {
+    // clear 'manuallyResizedColumnIndicesSet'
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * synchronize the handsontable alignment to the markdowntable alignment
+   */
+  const synchronizeAlignment = () => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const align = markdownTable.options.align;
+    const hotInstance = hotTable.hotInstance;
+
+    if (hotInstance.isDestroyed === true) {
+      return;
+    }
+
+    for (let i = 0; i < align.length; i++) {
+      for (let j = 0; j < hotInstance.countRows(); j++) {
+        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
+      }
+    }
+    hotInstance.render();
+  };
+
+  /**
+   * An afterLoadData hook
+   *
+   * This performs the following operations.
+   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
+   * - synchronize the handsontable alignment to the markdowntable alignment
+   *
+   * ## Note ##
+   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
+   *
+   * - markdownTable
+   * - handsontableHeight
+   *
+   * In detail, when the setState method is called with those state passed,
+   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
+   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
+   * In updateSettings method, the loadData method is called in some case.
+   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
+   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
+   * After the loadData method is executed, afterLoadData hooks are called.
+   */
+  const afterLoadDataHandler = (initialLoad: boolean) => {
+    if (initialLoad) {
+      manuallyResizedColumnIndicesSet.clear();
+    }
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * An afterColumnMove hook.
+   *
+   * This synchronizes alignment when columns are moved by manualColumnMove
+   */
+  // TODO: colums type is number[]
+  const afterColumnMoveHandler = (columns: any, target: number) => {
+    const align = [].concat(markdownTable.options.align);
+    const removed = align.splice(columns[0], columns.length);
+
+    /*
+      * The following is a description of the algorithm for the alignment synchronization.
+      *
+      * Consider the case where the target is X and the columns are [2,3] and data is as follows.
+      *
+      * 0 1 2 3 4 5 (insert position number)
+      * +-+-+-+-+-+
+      * | | | | | |
+      * +-+-+-+-+-+
+      *  0 1 2 3 4  (column index number)
+      *
+      * At first, remove columns by the splice.
+      *
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *  0 1     4
+      *
+      * Next, insert those columns into a new position.
+      * However the target number is a insert position number before deletion, it may be changed.
+      * These are changed as follows.
+      *
+      * Before:
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * After:
+      * 0 1 2   2 3
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
+      * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
+      *
+      */
+    let insertPosition = 0;
+    if (target <= columns[0]) {
+      insertPosition = target;
+    }
+    else if (columns[columns.length - 1] < target) {
+      insertPosition = target - columns.length;
+    }
+
+    for (let i = 0; i < removed.length; i++) {
+      align.splice(insertPosition + i, 0, removed[i]);
+    }
+
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align });
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * change the markdownTable alignment and synchronize the handsontable alignment to it
+   */
+  const align = (direction: string, startCol: number, endCol: number) => {
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align: [].concat(prevMarkdownTable.options.align) });
+      for (let i = startCol; i <= endCol; i++) {
+        newMarkdownTable.options.align[i] = direction;
+      }
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  const alignButtonHandler = (direction: string) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const selectedRange = hotTable.hotInstance.getSelectedRange();
+    if (selectedRange == null) return;
+
+    const startCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].from.col : selectedRange[0].to.col;
+    const endCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].to.col : selectedRange[0].from.col;
+
+    align(direction, startCol, endCol);
+  };
+
+  const toggleDataImportArea = () => {
+    setIsDataImportAreaExpanded(!isDataImportAreaExpanded);
+  };
+
+  /**
+   * Import a markdowntable
+   *
+   * ## Note ##
+   * The manualColumnMove operation affects the column order of imported data.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const importData = (markdownTable: MarkdownTable) => {
+    init(markdownTable);
+    toggleDataImportArea();
+  };
+
+  /**
+   * Expand the height of the Handsontable
+   *  by updating 'handsontableHeight' state
+   *  according to the height of this.refs.hotTableContainer
+   */
+  const expandHotTableHeight = () => {
+    if (isWindowExpanded && hotTableContainer != null) {
+      const height = hotTableContainer.getBoundingClientRect().height;
+      setHandsontableHeight(height);
+    }
+  };
+
+  // create debounced method for expanding HotTable
+  const expandHotTableHeightWithDebounce = debounce(100, expandHotTableHeight);
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+
+    // invoke updateHotTableHeight method with delay
+    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
+    expandHotTableHeightWithDebounce();
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+  };
+
+  const renderCloseButton = () => {
+    return (
+      <button type="button" className="close" onClick={cancel} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    );
+  };
+
+  const buttons = (
+    <span>
+      {/* change order because of `float: right` by '.close' class */}
+      {renderCloseButton()}
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        contractWindow={contractWindow}
+        expandWindow={expandWindow}
+      />
+    </span>
+  );
+
+  const createCustomizedContextMenu = () => {
+    return {
+      items: {
+        row_above: {},
+        row_below: {},
+        col_left: {},
+        col_right: {},
+        separator1: '---------',
+        remove_row: {},
+        remove_col: {},
+        separator2: '---------',
+        custom_alignment: {
+          name: 'Align columns',
+          key: 'align_columns',
+          submenu: {
+            items: [
+              {
+                name: 'Left',
+                key: 'align_columns:1',
+                callback: (key, selection) => { align('l', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Center',
+                key: 'align_columns:2',
+                callback: (key, selection) => { align('c', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Right',
+                key: 'align_columns:3',
+                callback: (key, selection) => { align('r', selection[0].start.col, selection[0].end.col) },
+              },
+            ],
+          },
+        },
+      },
+    };
+  };
+
+  // generate setting object for HotTable instance
+  const handsontableSettings = Object.assign(defaultHandsontableSetting(), {
+    contextMenu: createCustomizedContextMenu(),
+  });
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={cancel}
+      backdrop="static"
+      keyboard={false}
+      size="lg"
+      wrapClassName={`${styles['grw-handsontable']}`}
+      className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+    >
+      <ModalHeader tag="h4" toggle={cancel} close={buttons} className="bg-primary text-light">
+          Edit Table
+      </ModalHeader>
+      <ModalBody className="p-0 d-flex flex-column">
+        <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
+          <button
+            type="button"
+            className="mr-4 data-import-button btn btn-secondary"
+            data-toggle="collapse"
+            data-target="#collapseDataImport"
+            aria-expanded={isDataImportAreaExpanded}
+            onClick={toggleDataImportArea}
+          >
+            <span className="mr-3">Data Import</span><i className={isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
+          </button>
+          <div role="group" className="btn-group">
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('l') }}>
+              <i className="ti ti-align-left"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('c') }}>
+              <i className="ti ti-align-center"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('r') }}>
+              <i className="ti ti-align-right"></i>
+            </button>
+          </div>
+          <Collapse isOpen={isDataImportAreaExpanded}>
+            <div className="mt-4">
+              <MarkdownTableDataImportForm onCancel={toggleDataImportArea} onImport={importData} />
+            </div>
+          </Collapse>
+        </div>
+        <div ref={c => setHotTableContainer(c)} className="m-4 hot-table-container">
+          <HotTable
+            ref={c => setHotTable(c)}
+            data={markdownTable.table}
+            settings={handsontableSettings as Handsontable.DefaultSettings}
+            height={handsontableHeight}
+            afterLoadData={afterLoadDataHandler}
+            modifyColWidth={modifyColWidthHandler}
+            beforeColumnMove={beforeColumnMoveHandler}
+            beforeColumnResize={beforeColumnResizeHandler}
+            afterColumnResize={afterColumnResizeHandler}
+            afterColumnMove={afterColumnMoveHandler}
+          />
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="btn btn-danger" onClick={reset}>Reset</button>
+        <div className="ml-auto">
+          <button type="button" className="mr-2 btn btn-secondary" onClick={cancel}>Cancel</button>
+          <button type="button" className="btn btn-primary" onClick={save}>Done</button>
+        </div>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 2 - 0
packages/app/src/pages/[[...path]].page.tsx

@@ -80,6 +80,7 @@ const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialo
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -340,6 +341,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
+          <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
       </BasicLayout>

+ 29 - 2
packages/app/src/stores/modal.tsx

@@ -461,13 +461,40 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
   };
   const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
 
+  const open = (drawioMxFile: string): void => {
+    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  };
+
   const close = (): void => {
     swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
   };
 
-  const open = (drawioMxFile: string): void => {
-    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  return {
+    ...swrResponse,
+    open,
+    close,
   };
+};
+
+/*
+* HandsonTableModal
+*/
+type HandsontableModalStatus = {
+  isOpened: boolean,
+  table: string,
+}
+
+type HandsontableModalStatusUtils = {
+  open(table: string): Promise<HandsontableModalStatus | undefined>
+  close(): Promise<HandsontableModalStatus | undefined>
+}
+
+export const useHandsontableModal = (status?: HandsontableModalStatus): SWRResponse<HandsontableModalStatus, Error> & HandsontableModalStatusUtils => {
+  const initialData: HandsontableModalStatus = { isOpened: false, table: '' };
+  const swrResponse = useStaticSWR<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
+
+  const open = (table: string) => swrResponse.mutate({ isOpened: true, table });
+  const close = () => swrResponse.mutate({ isOpened: false, table: '' });
 
   return {
     ...swrResponse,

+ 0 - 19
packages/app/src/styles/style-next.scss

@@ -147,22 +147,3 @@
   }
 
 }
-
-// Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
-// see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
-.modal.in .modal-dialog.handsontable-modal {
-  transform: none;
-
-  .data-import-button {
-    position: relative;
-    padding-right: 35px;
-    padding-left: 10px;
-
-    i:before {
-      position: absolute;
-      top: 6px;
-      right: 8px;
-      font-size: 20px;
-    }
-  }
-}