Selaa lähdekoodia

Merge remote-tracking branch 'origin/master' into fix/saml

# Conflicts:
#	yarn.lock
Yuki Takei 7 vuotta sitten
vanhempi
sitoutus
73338337bf

+ 2 - 0
package.json

@@ -121,6 +121,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
+    "@handsontable/react": "^1.1.0",
     "autoprefixer": "^9.0.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
@@ -149,6 +150,7 @@
     "eslint-plugin-react": "^7.7.0",
     "extract-text-webpack-plugin": "^4.0.0-beta.0",
     "file-loader": "^2.0.0",
+    "handsontable": "^5.0.1",
     "i18next-browser-languagedetector": "^2.2.0",
     "imports-loader": "^0.8.0",
     "jquery-slimscroll": "^1.3.8",

+ 2 - 1
src/client/js/app.js

@@ -402,7 +402,8 @@ if (pageEditorOptionsSelectorElem) {
           // save
           crowi.saveEditorOptions(newEditorOptions);
           crowi.savePreviewOptions(newPreviewOptions);
-        }} />,
+        }}
+      />,
     pageEditorOptionsSelectorElem
   );
 }

+ 6 - 0
src/client/js/components/PageEditor/AbstractEditor.js

@@ -110,6 +110,12 @@ export default class AbstractEditor extends React.Component {
     }
   }
 
+  /**
+   * returns items(an array of react elements) in navigation bar for editor
+   */
+  getNavbarItems() {
+    return null;
+  }
 }
 
 AbstractEditor.propTypes = {

+ 14 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import Modal from 'react-bootstrap/es/Modal';
+import Button from 'react-bootstrap/es/Button';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
@@ -49,6 +50,7 @@ import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
+import HandsontableModal from './HandsontableModal';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -89,6 +91,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
+
+    this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
   }
 
   init() {
@@ -641,6 +645,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     );
   }
 
+  showHandsonTableHandler() {
+    this.refs.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
+  }
+
+  getNavbarItems() {
+    return <Button bsSize="small" onClick={ this.showHandsonTableHandler }><i className="icon-grid"></i></Button>;
+  }
+
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
     const defaultEditorOptions = {
@@ -653,6 +665,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
     return <React.Fragment>
+
       <ReactCodeMirror
         ref="cm"
         className={additionalClasses}
@@ -717,6 +730,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
       </div>
 
+      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceMarkdownTable(this.getCodeMirror(), table) }/>
     </React.Fragment>;
   }
 

+ 27 - 1
src/client/js/components/PageEditor/Editor.js

@@ -194,6 +194,31 @@ export default class Editor extends AbstractEditor {
     );
   }
 
+  renderNavbar() {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pr-4 nav nav-navbar navbar-right">
+          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
+            return <li key={idx}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }
+
+  getNavbarItems() {
+    // wait for rendering CodeMirrorEditor or TextAreaEditor
+    if (this.getEditorSubstance() == null) {
+      return null;
+    }
+
+    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
+    const navbarItems = [];
+
+    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
+  }
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -220,6 +245,8 @@ export default class Editor extends AbstractEditor {
 
           { this.state.dropzoneActive && this.renderDropzoneOverlay() }
 
+          { this.renderNavbar() }
+
           {/* for PC */}
           { !isMobile &&
             <CodeMirrorEditor
@@ -253,7 +280,6 @@ export default class Editor extends AbstractEditor {
             or pasting from the clipboard.
           </span>
         </button>
-
       </div>
     );
   }

+ 98 - 0
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Modal from 'react-bootstrap/es/Modal';
+import Button from 'react-bootstrap/es/Button';
+
+import { HotTable } from '@handsontable/react';
+
+import MarkdownTable from '../../models/MarkdownTable';
+
+export default class HandsontableModal extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
+      markdownTable: HandsontableModal.getDefaultMarkdownTable()
+    };
+
+    this.settings = {
+      height: 300,
+      rowHeaders: true,
+      colHeaders: true,
+      fixedRowsTop: [0, 1],
+      contextMenu: ['row_above', 'row_below', 'col_left', 'col_right', '---------', 'remove_row', 'remove_col', '---------', 'alignment'],
+      stretchH: 'all',
+      selectionMode: 'multiple',
+    };
+
+    this.init = this.init.bind(this);
+    this.reset = this.reset.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.save = this.save.bind(this);
+  }
+
+  init(markdownTable) {
+    const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
+    this.setState({ markdownTableOnInit: initMarkdownTable });
+    this.setState({ markdownTable: initMarkdownTable.clone() });
+  }
+
+  show(markdownTable) {
+    this.init(markdownTable);
+    this.setState({ show: true });
+  }
+
+  reset() {
+    this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
+  }
+
+  cancel() {
+    this.setState({ show: false });
+  }
+
+  save() {
+    if (this.props.onSave != null) {
+      this.props.onSave(this.state.markdownTable);
+    }
+    this.setState({ show: false });
+  }
+
+  render() {
+    return (
+      <Modal show={this.state.show} onHide={this.cancel} bsSize="large">
+        <Modal.Header closeButton>
+          <Modal.Title>Edit Table</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="p-0">
+          <div className="p-4">
+            <HotTable data={this.state.markdownTable.table} settings={this.settings} />
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <div className="d-flex justify-content-between">
+            <Button bsStyle="danger" onClick={this.reset}>Reset</Button>
+            <div className="d-flex">
+              <Button bsStyle="default" onClick={this.cancel}>Cancel</Button>
+              <Button bsStyle="primary" onClick={this.save}>Done</Button>
+            </div>
+          </div>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+  static getDefaultMarkdownTable() {
+    return new MarkdownTable([
+      ['col1', 'col2', 'col3'],
+      ['', '', ''],
+      ['', '', ''],
+    ]);
+  }
+}
+
+HandsontableModal.propTypes = {
+  onSave: PropTypes.func
+};

+ 3 - 2
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,6 +1,7 @@
 import { BasicInterceptor } from 'growi-pluginkit';
 
 import mtu from './MarkdownTableUtil';
+import MarkdownTable from '../../models/MarkdownTable';
 
 /**
  * Interceptor for markdown table
@@ -47,12 +48,12 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     if (mtu.isEndOfLine(cm) && mtu.linePartOfTableRE.test(strFromBol)) {
       // get lines all of table from current position to beginning of table
       const strFromBot = mtu.getStrFromBot(cm);
-      let table = mtu.parseFromTableStringToMarkdownTable(strFromBot);
+      let table = MarkdownTable.fromMarkdownString(strFromBot);
 
       mtu.addRowToMarkdownTable(table);
 
       const strToEot = mtu.getStrToEot(cm);
-      const tableBottom = mtu.parseFromTableStringToMarkdownTable(strToEot);
+      const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
       if (tableBottom.table.length > 0) {
         table = mtu.mergeMarkdownTable([table, tableBottom]);
       }

+ 39 - 72
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -1,5 +1,4 @@
-import markdownTable from 'markdown-table';
-import stringWidth from 'string-width';
+import MarkdownTable from '../../models/MarkdownTable';
 
 /**
  * Utility for markdown table
@@ -18,18 +17,22 @@ class MarkdownTableUtil {
     this.getBol = this.getBol.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
-
-    this.parseFromTableStringToMarkdownTable = this.parseFromTableStringToMarkdownTable.bind(this);
-    this.replaceMarkdownTableWithReformed = this.replaceMarkdownTableWithReformed.bind(this);
+    this.isInTable = this.isInTable.bind(this);
+    this.replaceMarkdownTable = this.replaceMarkdownTable.bind(this);
+    this.replaceMarkdownTableWithReformed = this.replaceMarkdownTable; // alias
   }
 
   /**
    * return the postion of the BOT(beginning of table)
-   * (It is assumed that current line is a part of table)
+   * (If the cursor is not in a table, return its position)
    */
   getBot(editor) {
-    const firstLine = editor.getDoc().firstLine();
     const curPos = editor.getCursor();
+    if (!this.isInTable(editor)) {
+      return { line: curPos.line, ch: curPos.ch};
+    }
+
+    const firstLine = editor.getDoc().firstLine();
     let line = curPos.line - 1;
     for (; line >= firstLine; line--) {
       const strLine = editor.getDoc().getLine(line);
@@ -43,11 +46,15 @@ class MarkdownTableUtil {
 
   /**
    * return the postion of the EOT(end of table)
-   * (It is assumed that current line is a part of table)
+   * (If the cursor is not in a table, return its position)
    */
   getEot(editor) {
-    const lastLine = editor.getDoc().lastLine();
     const curPos = editor.getCursor();
+    if (!this.isInTable(editor)) {
+      return { line: curPos.line, ch: curPos.ch};
+    }
+
+    const lastLine = editor.getDoc().lastLine();
     let line = curPos.line + 1;
     for (; line <= lastLine; line++) {
       const strLine = editor.getDoc().getLine(line);
@@ -69,7 +76,7 @@ class MarkdownTableUtil {
   }
 
   /**
-   * return strings from BOT(beginning of table) to current position
+   * return strings from BOT(beginning of table) to the cursor position
    */
   getStrFromBot(editor) {
     const curPos = editor.getCursor();
@@ -77,7 +84,7 @@ class MarkdownTableUtil {
   }
 
   /**
-   * return strings from current position to EOT(end of table)
+   * return strings from the cursor position to EOT(end of table)
    */
   getStrToEot(editor) {
     const curPos = editor.getCursor();
@@ -85,52 +92,34 @@ class MarkdownTableUtil {
   }
 
   /**
-   * returns markdown table whose described by 'markdown-table' format
-   *   ref. https://github.com/wooorm/markdown-table
-   * @param {string} lines all of table
+   * return MarkdownTable instance of the table where the cursor is
+   * (If the cursor is not in a table, return null)
    */
-  parseFromTableStringToMarkdownTable(strMDTable) {
-    const arrMDTableLines = strMDTable.split(/(\r\n|\r|\n)/);
-    let contents = [];
-    let aligns = [];
-    for (let n = 0; n < arrMDTableLines.length; n++) {
-      const line = arrMDTableLines[n];
-
-      if (this.tableAlignmentLineRE.test(line) && !this.tableAlignmentLineNegRE.test(line)) {
-        // parse line which described alignment
-        const alignRuleRE = [
-          { align: 'c', regex: /^:-+:$/ },
-          { align: 'l', regex: /^:-+$/  },
-          { align: 'r', regex: /^-+:$/  },
-        ];
-        let lineText = '';
-        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        lineText = lineText.replace(/\s*/g, '');
-        aligns = lineText.split(/\|/).map(col => {
-          const rule = alignRuleRE.find(rule => col.match(rule.regex));
-          return (rule != undefined) ? rule.align : '';
-        });
-      }
-      else if (this.linePartOfTableRE.test(line)) {
-        // parse line whether header or body
-        let lineText = '';
-        lineText = line.replace(/\s*\|\s*/g, '|');
-        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        const row = lineText.split(/\|/);
-        contents.push(row);
-      }
+  getMarkdownTable(editor) {
+    if (!this.isInTable(editor)) {
+      return null;
     }
-    return (new MarkdownTable(contents, { align: aligns, stringLength: stringWidth }));
+
+    const strFromBotToEot = editor.getDoc().getRange(this.getBot(editor), this.getEot(editor));
+    return MarkdownTable.fromMarkdownString(strFromBotToEot);
   }
 
   /**
-   * return boolean value whether the current position of cursor is end of line
+   * return boolean value whether the cursor position is end of line
    */
   isEndOfLine(editor) {
     const curPos = editor.getCursor();
     return (curPos.ch == editor.getDoc().getLine(curPos.line).length);
   }
 
+  /**
+   * return boolean value whether the cursor position is in a table
+   */
+  isInTable(editor) {
+    const curPos = editor.getCursor();
+    return this.linePartOfTableRE.test(editor.getDoc().getLine(curPos.line));
+  }
+
   /**
    * add a row at the end
    * (This function overwrite directory markdown table specified as argument.)
@@ -163,39 +152,17 @@ class MarkdownTableUtil {
   }
 
   /**
-   * replace markdown table which is reformed by markdown-table
+   * replace markdown table
+   * (A replaced table is reformed by markdown-table.)
    * @param {MarkdownTable} markdown table
    */
-  replaceMarkdownTableWithReformed(editor, table) {
+  replaceMarkdownTable(editor, table) {
     const curPos = editor.getCursor();
-
-    // replace the lines to strTableLinesFormated
-    const strTableLinesFormated = table.toString();
-    editor.getDoc().replaceRange(strTableLinesFormated, this.getBot(editor), this.getEot(editor));
-
-    // set cursor to first column
+    editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
     editor.getDoc().setCursor(curPos.line + 1, 2);
   }
 }
 
-/**
- * markdown table class for markdown-table module
- *   ref. https://github.com/wooorm/markdown-table
- */
-class MarkdownTable {
-
-  constructor(table, options) {
-    this.table = table || [];
-    this.options = options || {};
-
-    this.toString = this.toString.bind(this);
-  }
-
-  toString() {
-    return markdownTable(this.table, this.options);
-  }
-}
-
 // singleton pattern
 const instance = new MarkdownTableUtil();
 Object.freeze(instance);

+ 4 - 6
src/client/js/components/PageEditor/OptionsSelector.js

@@ -108,9 +108,7 @@ export default class OptionsSelector extends React.Component {
    * dispatch onChange event
    */
   dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.editorOptions, this.state.previewOptions);
-    }
+    this.props.onChange(this.state.editorOptions, this.state.previewOptions);
   }
 
   renderThemeSelector() {
@@ -255,7 +253,7 @@ export class PreviewOptions {
 
 OptionsSelector.propTypes = {
   crowi: PropTypes.object.isRequired,
-  editorOptions: PropTypes.instanceOf(EditorOptions),
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
-  onChange: PropTypes.func,
+  editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
+  previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
+  onChange: PropTypes.func.isRequired,
 };

+ 82 - 0
src/client/js/models/MarkdownTable.js

@@ -0,0 +1,82 @@
+import markdownTable from 'markdown-table';
+import stringWidth from 'string-width';
+
+// https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
+// https://regex101.com/r/7BN2fR/7
+const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
+const tableAlignmentLineNegRE = /^[^-:]*$/;  // it is need to check to ignore empty row which is matched above RE
+const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+
+/**
+ * markdown table class for markdown-table module
+ *   ref. https://github.com/wooorm/markdown-table
+ */
+export default class MarkdownTable {
+
+  constructor(table, options) {
+    this.table = table || [];
+    this.options = options || {};
+
+    this.toString = this.toString.bind(this);
+  }
+
+  toString() {
+    return markdownTable(this.table, this.options);
+  }
+
+  /**
+   * returns cloned Markdowntable instance
+   * (This method clones only the table field.)
+   */
+  clone() {
+    let newTable = [];
+    for (let i = 0; i < this.table.length; i++) {
+      newTable.push([].concat(this.table[i]));
+    }
+    return new MarkdownTable(newTable, this.options);
+  }
+
+  static fromTableTag(str) {
+    // TODO impl
+    return new MarkdownTable();
+  }
+
+  /**
+   * returns MarkdownTable instance
+   *   ref. https://github.com/wooorm/markdown-table
+   * @param {string} str markdown string
+   */
+  static fromMarkdownString(str) {
+    const arrMDTableLines = str.split(/(\r\n|\r|\n)/);
+    let contents = [];
+    let aligns = [];
+    for (let n = 0; n < arrMDTableLines.length; n++) {
+      const line = arrMDTableLines[n];
+
+      if (tableAlignmentLineRE.test(line) && !tableAlignmentLineNegRE.test(line)) {
+        // parse line which described alignment
+        const alignRuleRE = [
+          { align: 'c', regex: /^:-+:$/ },
+          { align: 'l', regex: /^:-+$/  },
+          { align: 'r', regex: /^-+:$/  },
+        ];
+        let lineText = '';
+        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        lineText = lineText.replace(/\s*/g, '');
+        aligns = lineText.split(/\|/).map(col => {
+          const rule = alignRuleRE.find(rule => col.match(rule.regex));
+          return (rule != undefined) ? rule.align : '';
+        });
+      }
+      else if (linePartOfTableRE.test(line)) {
+        // parse line whether header or body
+        let lineText = '';
+        lineText = line.replace(/\s*\|\s*/g, '|');
+        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        const row = lineText.split(/\|/);
+        contents.push(row);
+      }
+    }
+    return (new MarkdownTable(contents, { align: aligns, stringLength: stringWidth }));
+  }
+}

+ 6 - 14
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -211,31 +211,20 @@ legend {
 /*
  * Tabs
  */
+$active-nav-tabs-bgcolor: $bodycolor !default;
 .nav.nav-tabs {
-  > li > a {
-    &, &:hover, &:focus {
-      background: transparent;
-    }
-  }
-  > li.active > a {
-    background: $bodycolor;
-    border-bottom: 1px solid $bodycolor;
-  }
-}
 
-/*
- * Tabs
- */
- .nav.nav-tabs {
   border-bottom-color: $navbar-border;
 
   > li > a {
     color:$linktext;
     &:hover, &:focus {
       color: $linktext-hover;
+      background: transparent;
     }
   }
   > li.active > a {
+    background: $active-nav-tabs-bgcolor;
     border-top-color: $navbar-border;
     border-left-color: $navbar-border;
     border-right-color: $navbar-border;
@@ -309,6 +298,9 @@ body.on-edit {
 
     .page-editor-editor-container {
       border-right-color: $navbar-border;
+      .navbar-editor {
+        background-color: $active-nav-tabs-bgcolor;   // same color with active tab
+      }
     }
     .page-editor-preview-container {
       background-color: $bodycolor;

+ 12 - 6
src/client/styles/agile-admin/inverse/colors/halloween.scss

@@ -22,6 +22,7 @@ $dark: darken($bodytext, 5%);
 $border: $themecolor;
 $navbar-border: lighten($basecolor, 25%);
 $active-navbar-border: darken($navbar-border, 3%);
+$active-nav-tabs-bgcolor: #231313;
 $btn-default-bgcolor: darken($basecolor, 10%);
 $inline-code-color: #a94f04;
 $inline-code-bg: #0a121b;
@@ -63,7 +64,8 @@ $inline-code-bg: #0a121b;
   background-image: url("/images/themes/halloween/halloween-navbar.jpg");
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/halloween/halloween.jpg");
   background-attachment: fixed;
 }
@@ -72,10 +74,14 @@ $inline-code-bg: #0a121b;
   background-color: rgba(0, 0, 0, 0.3);
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid #1f1b1b;
+/*
+ * Tabs
+ */
+body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
   }
 }

+ 14 - 7
src/client/styles/agile-admin/inverse/colors/island.scss

@@ -8,7 +8,7 @@ $linkcolor: #3c6d72;
 $themecolor: #97cbc3;
 $topbar: #0c2a44;
 $sidebar: $themelight;
-$bodycolor: lighten($themelight, 5%);
+$bodycolor: lighten($themelight, 10%);
 $headingtext:#3c6d72;
 $bodytext: #3c6d72;
 $linktext: $linkcolor;
@@ -22,6 +22,7 @@ $dark: darken($bodytext, 5%);
 $border: #76b1a8;
 $navbar-border: #76b1a8;
 $active-navbar-border: darken($navbar-border, 13%);
+$active-nav-tabs-bgcolor: #dbf0ed;
 $btn-default-bgcolor: darken($themecolor, 10%);
 $inline-code-color: #8f5313;
 $inline-code-bg: darken($themelight, 3%);
@@ -74,18 +75,24 @@ $inline-code-bg: darken($themelight, 3%);
   background: lighten($themelight, 5%);
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/island/island.png");
   background-attachment: fixed;
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid #d0ece7;
+/*
+ * Tabs
+ */
+ body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
   }
 }
+
 /* Table */
  .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th,
  .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td ,

+ 15 - 9
src/client/styles/agile-admin/inverse/colors/wood.scss

@@ -18,6 +18,7 @@ $wikilinktext: lighten($themecolor, 5%);
 $wikilinktext-hover: lighten($wikilinktext, 15%);
 $inline-code-color: darken($themecolor, 20%);
 $inline-code-bg: lighten($subthemecolor, 70%);
+$active-nav-tabs-bgcolor: #fffffc;
 
 @import 'apply-colors';
 @import 'apply-colors-light';
@@ -39,7 +40,8 @@ $inline-code-bg: lighten($subthemecolor, 70%);
   background: $themelight;
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/wood/wood.jpg");
   background-attachment: fixed;
 }
@@ -48,18 +50,22 @@ $inline-code-bg: lighten($subthemecolor, 70%);
   background-color: rgba(226, 221, 192, 0.205);
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid $bodycolor;
-  }
-}
-
 #wrapper > .navbar > .navbar-header {
   background-image: url("/images/themes/wood/wood-navbar.jpg");
 }
 
+/*
+ * Tabs
+ */
+body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
+  }
+}
+
 // login page
 .nologin {
   .input-group {

+ 3 - 1
src/client/styles/scss/_mixins.scss

@@ -16,7 +16,9 @@
 
 @mixin expand-editor($header-plus-footer) {
   $header-plus-footer: $header-plus-footer + 2px;   // add .main padding-top
-  $editor-margin: $header-plus-footer + 26px;       // add .btn-open-dropzone height
+  $editor-margin: $header-plus-footer
+                  + 25px        // add .btn-open-dropzone height
+                  + 30px;       // add .navbar-editor height
 
   .main {
     width: 100%;

+ 5 - 4
src/client/styles/scss/_on-edit.scss

@@ -87,11 +87,12 @@ body.on-edit {
     z-index: 1;
     left: $left-margin;
     width: calc(100% - #{$left-margin} - #{$right-margin});
+    pointer-events: none;                               // disable pointer-events because it becomes an obstacle
 
-    // for crowi layout
-    > .col-md-9, .col-xs-12 {
-      padding: 0;
-      width: 100%;
+    > .header-container {
+      pointer-events: initial;                          // enable pointer-events
+      padding: 0;   // for crowi layout
+      width: 100%;  // for crowi layout
     }
 
     background: none;

+ 3 - 0
src/client/styles/scss/_override-handsontable.scss

@@ -0,0 +1,3 @@
+.modal .handsontable .wtBorder {
+  z-index: 110;
+}

+ 3 - 0
src/client/styles/scss/_vendor.scss

@@ -18,3 +18,6 @@ $bootstrap-sass-asset-helper: true;
 @import '~codemirror/lib/codemirror.css';
 @import '~codemirror/theme/elegant.css';
 @import '~codemirror/theme/eclipse.css';
+
+// import Handsontable styles
+@import '~handsontable/dist/handsontable.full.css';

+ 3 - 0
src/client/styles/scss/style.scss

@@ -14,6 +14,9 @@
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 
+// override Handsontable styles
+@import 'override-handsontable';
+
 // crowi component
 @import 'admin';
 @import 'attachments';

+ 1 - 1
src/server/views/layout-crowi/base/layout.html

@@ -25,7 +25,7 @@
   </aside>
 
   <div class="row hidden-print bg-title">
-    <div class="col-md-9">
+    <div class="col-md-9 header-container">
       {% block content_header %}
       {% endblock %}
     </div>

+ 1 - 1
src/server/views/layout-growi/base/layout.html

@@ -4,7 +4,7 @@
 <div class="container-fluid">
 
   <div class="row bg-title hidden-print">
-    <div class="col-xs-12">
+    <div class="col-xs-12 header-container">
       {% block content_header %}
       {% endblock %}
     </div>

+ 1 - 0
src/server/views/widget/page_modals.html

@@ -3,3 +3,4 @@
 {% include '../modal/create_template.html' %}
 {% include '../modal/duplicate.html' %}
 {% include '../modal/put_back.html' %}
+<div id="handsontable-modal" />

+ 33 - 5
yarn.lock

@@ -27,6 +27,10 @@
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.3.0.tgz#a052303ae5d1a1f9b63eeb3a94495a2f429f4831"
 
+"@handsontable/react@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-1.1.0.tgz#7f7cc822bc4cfab26f843792982ef81838e82d07"
+
 "@sinonjs/formatio@^2.0.0":
   version "2.0.0"
   resolved "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2"
@@ -1280,6 +1284,10 @@ big.js@^3.1.3:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
 
+bignumber.js@^4.0.4:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-4.1.0.tgz#db6f14067c140bd46624815a7916c92d9b6c24b1"
+
 binary-extensions@^1.0.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
@@ -3882,6 +3890,14 @@ gzip-size@^3.0.0:
   dependencies:
     duplexer "^0.1.1"
 
+handsontable@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/handsontable/-/handsontable-5.0.1.tgz#4aadbaf1a468d8c7b3cdbf8a5f49c4110879c373"
+  dependencies:
+    moment "2.20.1"
+    numbro "^2.0.6"
+    pikaday "1.5.1"
+
 har-schema@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
@@ -5610,14 +5626,14 @@ module-alias@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.0.6.tgz#abb2cfa07014f503514ad5061c6f03d79b591889"
 
-moment@2.22.2:
-  version "2.22.2"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
-
-moment@^2.10.6:
+moment@2.20.1, moment@^2.10.6:
   version "2.20.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
 
+moment@2.22.2, moment@2.x:
+  version "2.22.2"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
+
 mongodb-core@2.1.19:
   version "2.1.19"
   resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.1.19.tgz#00fbd5e5a3573763b9171cfd844e60a8f2a3a18b"
@@ -6101,6 +6117,12 @@ number-is-nan@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
 
+numbro@^2.0.6:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/numbro/-/numbro-2.1.0.tgz#618ac6e4b2f32f2e623190ce4b05f4c8b09c3207"
+  dependencies:
+    bignumber.js "^4.0.4"
+
 oauth-sign@~0.8.1, oauth-sign@~0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
@@ -6566,6 +6588,12 @@ pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
 
+pikaday@1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3"
+  optionalDependencies:
+    moment "2.x"
+
 pinkie-promise@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"