jam411 3 лет назад
Родитель
Сommit
e7ca54d36b

+ 2 - 1
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');

+ 1 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -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';

+ 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,
-};

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

@@ -0,0 +1,523 @@
+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 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 { onSave, autoFormatMarkdownTable } = props;
+
+  const [hotTable, setHotTable] = useState<HotTable | null>();
+  const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
+
+  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 [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();
+  };
+
+  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) },
+              },
+            ],
+          },
+        },
+      },
+    };
+  };
+
+  // const show = () => {
+  //   init(markdownTable);
+  //   setIsShow(true);
+  // };
+
+  const hide = () => {
+    setIsShow(false);
+    setIsDataImportAreaExpanded(false);
+    setIsWindowExpanded(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
+   */
+  const reset = () => {
+    setMarkdownTable(markdownTableOnInit.clone());
+  };
+
+  const cancel = () => {
+    hide();
+  };
+
+  const markdownTableOption = () => {
+    return {
+      align: [].concat(markdownTable.options.align),
+      pad: autoFormatMarkdownTable !== false,
+    };
+  };
+
+  const save = () => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const markdownTable = new MarkdownTable(
+      hotTable.hotInstance.getData(),
+      markdownTableOption,
+    ).normalizeCells();
+
+    if (onSave != null) {
+      onSave(markdownTable);
+    }
+
+    hide();
+  };
+
+  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) => {
+    if (initialLoad) {
+      manuallyResizedColumnIndicesSet.clear();
+    }
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * An afterColumnMove hook.
+   *
+   * This synchronizes alignment when columns are moved by manualColumnMove
+   */
+  const afterColumnMoveHandler = (columns, target) => {
+    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, 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) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const selectedRange = 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;
+    }
+
+    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) => {
+    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>
+  );
+
+  // generate setting object for HotTable instance
+  const handsontableSettings = Object.assign(defaultHandsontableSetting(), {
+    contextMenu: createCustomizedContextMenu(),
+  });
+
+  return (
+    <Modal
+      isOpen={isShow}
+      toggle={cancel}
+      backdrop="static"
+      keyboard={false}
+      size="lg"
+      className={`handsontable-modal ${styles['grw-handsontable']}
+        ${isWindowExpanded && `grw-modal-expanded ${styles['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

@@ -23,6 +23,7 @@ import { Comments } from '~/components/Comments';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
 import { CurrentPageContentFooter } from '~/components/PageContentFooter';
+import { HandsontableModal } from '~/components/PageEditor/HandsontableModal';
 import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
@@ -340,6 +341,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
+          <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
       </BasicLayout>

+ 24 - 0
packages/app/src/stores/modal.tsx

@@ -33,6 +33,30 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
 };
 
+/*
+* HandsonTableModal
+*/
+type HandsontableModalStatus = {
+  isOpened: boolean,
+  path?: string,
+}
+
+type HandsontableModalStatusUtils = {
+  open(path?: string): Promise<HandsontableModalStatus | undefined>
+  close(): Promise<HandsontableModalStatus | undefined>
+}
+
+export const useHandsontableModal = (status?: HandsontableModalStatus): SWRResponse<HandsontableModalStatus, Error> & HandsontableModalStatusUtils => {
+  const initialData: HandsontableModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (path?: string) => swrResponse.mutate({ isOpened: true, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
 /*
 * PageDeleteModal
 */