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

Merge pull request #3229 from weseek/feat/3176-grid-edit-modal-for-master-merge

Feat/3176 grid edit modal for master merge
Yuki Takei 5 лет назад
Родитель
Сommit
47d9af5e03

+ 12 - 0
resource/locales/en_US/translation.json

@@ -6,6 +6,10 @@
   "delete_all": "Delete all",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "Duplicate": "Duplicate",
   "Copy": "Copy",
   "Copy": "Copy",
+  "preview":"Preview",
+  "desktop":"Desktop",
+  "phone":"Smartphone",
+  "tablet":"Tablet",
   "Click to copy": "Click to copy",
   "Click to copy": "Click to copy",
   "Move/Rename": "Move/Rename",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
   "Moved": "Moved",
@@ -803,6 +807,14 @@
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
   },
   },
+  "grid_edit":{
+    "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
+    "grid_settings": "Grid Settings",
+    "grid_pattern":"Grid Pattern",
+    "division":"Divisions",
+    "smart_no":"Smartphone / No Break",
+    "break_point":"Break point by display size"
+  },
   "validation":{
   "validation":{
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",

+ 12 - 0
resource/locales/ja_JP/translation.json

@@ -6,6 +6,10 @@
   "delete_all": "全て削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "Duplicate": "複製",
   "Copy": "コピー",
   "Copy": "コピー",
+  "preview":"プレビュー",
+  "desktop":"パソコン",
+  "phone":"スマホ",
+  "tablet":"タブレット",
   "Click to copy": "クリックでコピー",
   "Click to copy": "クリックでコピー",
   "Move/Rename": "移動/名前変更",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
   "Moved": "移動しました",
@@ -796,6 +800,14 @@
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
   },
   },
+  "grid_edit":{
+    "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
+    "grid_settings": "グリッド設定",
+    "grid_pattern":"グリッド パターン",
+    "division":"分割",
+    "smart_no":"スマホ / 分割なし",
+    "break_point":"画面サイズより分割"
+  },
   "validation":{
   "validation":{
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",

+ 12 - 0
resource/locales/zh_CN/translation.json

@@ -6,6 +6,10 @@
 	"delete_all": "删除所有",
 	"delete_all": "删除所有",
 	"Duplicate": "复制",
 	"Duplicate": "复制",
 	"Copy": "复制",
 	"Copy": "复制",
+  "preview":"预览",
+  "desktop":"电脑",
+  "phone":"手机",
+  "tablet":"平板",
 	"Login": "登录",
 	"Login": "登录",
 	"Click to copy": "点击复制",
 	"Click to copy": "点击复制",
 	"Move/Rename": "移动/重命名",
 	"Move/Rename": "移动/重命名",
@@ -807,6 +811,14 @@
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+	},
+  "grid_edit":{
+    "create_bootstrap_4_grid":"创建Bootstrap 4网格",
+    "grid_settings": "网格设置",
+    "grid_pattern": "网格样式",
+    "division":"分割",
+    "smart_no":"手机/不分割",
+    "break_point":"按画面大小分割"
   },
   },
   "validation":{
   "validation":{
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",

+ 3 - 0
src/client/js/components/Page.jsx

@@ -11,6 +11,7 @@ import MarkdownTable from '../models/MarkdownTable';
 
 
 import LinkEditModal from './PageEditor/LinkEditModal';
 import LinkEditModal from './PageEditor/LinkEditModal';
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
+import GridEditModal from './PageEditor/GridEditModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import DrawioModal from './PageEditor/DrawioModal';
 import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
@@ -30,6 +31,7 @@ class Page extends React.Component {
 
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
 
+    this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
     this.drawioModal = React.createRef();
@@ -139,6 +141,7 @@ class Page extends React.Component {
 
 
         { isLoggedIn && (
         { isLoggedIn && (
           <>
           <>
+            <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.LinkEditModal} />
             <LinkEditModal ref={this.LinkEditModal} />
             <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
             <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />

+ 21 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -19,6 +19,8 @@ import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mlu from './MarkdownLinkUtil';
 import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
 import mdu from './MarkdownDrawioUtil';
+import geu from './GridEditorUtil';
+import GridEditModal from './GridEditModal';
 import LinkEditModal from './LinkEditModal';
 import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
@@ -74,6 +76,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
       additionalClassSet: new Set(),
     };
     };
 
 
+    this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
     this.drawioModal = React.createRef();
@@ -102,6 +105,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
 
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
+    this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
@@ -666,6 +670,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     cm.focus();
     cm.focus();
   }
   }
 
 
+  showGridEditorHandler() {
+    this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
+  }
+
   showLinkEditHandler() {
   showLinkEditHandler() {
     this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
     this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
   }
   }
@@ -779,6 +787,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
       >
         <EditorIcon icon="Image" />
         <EditorIcon icon="Image" />
       </Button>,
       </Button>,
+      <Button
+        key="nav-item-grid"
+        color={null}
+        size="sm"
+        title="Grid"
+        onClick={this.showGridEditorHandler}
+      >
+        <EditorIcon icon="Grid" />
+      </Button>,
       <Button
       <Button
         key="nav-item-table"
         key="nav-item-table"
         color={null}
         color={null}
@@ -870,6 +887,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
         { this.renderCheatsheetOverlay() }
         { this.renderCheatsheetOverlay() }
 
 
+        <GridEditModal
+          ref={this.gridEditModal}
+          onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
+        />
         <LinkEditModal
         <LinkEditModal
           ref={this.linkEditModal}
           ref={this.linkEditModal}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}

+ 20 - 0
src/client/js/components/PageEditor/EditorIcon.jsx

@@ -84,6 +84,26 @@ const EditorIcon = (props) => {
           <path d="M22,8H8A1,1,0,0,0,7,9V21a1,1,0,0,0,1,1H22a1,1,0,0,0,1-1V9A1,1,0,0,0,22,8Zm0,13H8V18l4.07-4.06,4.07,4a.41.41,0,0,0,.33.18.4.4,0,0,0,.32-.18l1.7-1.55,3.17,3.25L22,20Zm0-2.25-3.1-3.34a.89.89,0,0,0-.33-.17.89.89,0,0,0-.28.14l-1.83,1.49-4-3.9a.49.49,0,0,0-.32-.16.5.5,0,0,0-.41.16L8,16.75V9H22ZM19.5,12.5a1,1,0,1,1-1-1A1,1,0,0,1,19.5,12.5Z" />
           <path d="M22,8H8A1,1,0,0,0,7,9V21a1,1,0,0,0,1,1H22a1,1,0,0,0,1-1V9A1,1,0,0,0,22,8Zm0,13H8V18l4.07-4.06,4.07,4a.41.41,0,0,0,.33.18.4.4,0,0,0,.32-.18l1.7-1.55,3.17,3.25L22,20Zm0-2.25-3.1-3.34a.89.89,0,0,0-.33-.17.89.89,0,0,0-.28.14l-1.83,1.49-4-3.9a.49.49,0,0,0-.32-.16.5.5,0,0,0-.41.16L8,16.75V9H22ZM19.5,12.5a1,1,0,1,1-1-1A1,1,0,0,1,19.5,12.5Z" />
         </svg>
         </svg>
       );
       );
+    case 'Grid':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
+          <rect width="30" height="30" fill="none" />
+          <g transform="translate(-375 -415)">
+            <g transform="translate(382 422)">
+              <path d="M5,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H5A.945.945,0,0,1,6,1V6A.945.945,0,0,1,5,7ZM1,1V6H5V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(390 422)">
+              <path d="M7,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H7A.945.945,0,0,1,8,1V6A.945.945,0,0,1,7,7ZM1,1V6H7V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(382 431)">
+              <path d="M9,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H9a.945.945,0,0,1,1,1V6A.945.945,0,0,1,9,7ZM1,1V6H9V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(394 431)">
+              <path d="M3,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H3A.945.945,0,0,1,4,1V6A.945.945,0,0,1,3,7ZM1,1V6H3V1ZM1,.5V1H1Z" />
+            </g>
+          </g>
+        </svg>
+      );
     case 'Table':
     case 'Table':
       return (
       return (
         <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">
         <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">

+ 252 - 0
src/client/js/components/PageEditor/GridEditModal.jsx

@@ -0,0 +1,252 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import geu from './GridEditorUtil';
+import BootstrapGrid from '../../models/BootstrapGrid';
+
+const resSizes = BootstrapGrid.ResponsiveSize;
+const resSizeObj = {
+  [resSizes.XS_SIZE]: { iconClass: 'icon-screen-smartphone', displayText: 'grid_edit.smart_no' },
+  [resSizes.SM_SIZE]: { iconClass: 'icon-screen-tablet', displayText: 'tablet' },
+  [resSizes.MD_SIZE]: { iconClass: 'icon-screen-desktop', displayText: 'desktop' },
+};
+class GridEditModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      colsRatios: [6, 6],
+      responsiveSize: BootstrapGrid.ResponsiveSize.XS_SIZE,
+      show: false,
+      // use when re-edit grid
+      // gridHtml: '',
+    };
+
+    this.checkResposiveSize = this.checkResposiveSize.bind(this);
+    this.checkColsRatios = this.checkColsRatios.bind(this);
+    // use when re-edit grid
+    // this.init = this.init.bind(this);
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.pasteCodedGrid = this.pasteCodedGrid.bind(this);
+    this.renderSelectedGridPattern = this.renderSelectedGridPattern.bind(this);
+    this.renderBreakPointSetting = this.renderBreakPointSetting.bind(this);
+  }
+
+  async checkResposiveSize(rs) {
+    await this.setState({ responsiveSize: rs });
+  }
+
+  async checkColsRatios(cr) {
+    await this.setState({ colsRatios: cr });
+  }
+
+  // use when re-edit grid
+  // init(gridHtml) {
+  //   const initGridHtml = gridHtml;
+  //   this.setState({ gridHtml: initGridHtml });
+  // }
+
+  show(gridHtml) {
+    // use when re-edit grid
+    // this.init(gridHtml);
+    this.setState({ show: true });
+  }
+
+  hide() {
+    this.setState({ show: false });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  pasteCodedGrid() {
+    const { colsRatios, responsiveSize } = this.state;
+    const convertedHTML = geu.convertRatiosAndSizeToHTML(colsRatios, responsiveSize);
+    const spaceTab = '    ';
+    const pastedGridData = `::: editable-row\n<div class="container">\n${spaceTab}<div class="row">\n${convertedHTML}\n${spaceTab}</div>\n</div>\n:::`;
+
+    if (this.props.onSave != null) {
+      this.props.onSave(pastedGridData);
+    }
+    this.cancel();
+  }
+
+  renderSelectedGridPattern() {
+    const colsRatios = this.state.colsRatios;
+    return colsRatios.join(' - ');
+  }
+
+  renderBreakPointSetting() {
+    const { t } = this.props;
+    const output = Object.entries(resSizeObj).map((responsiveSizeForMap) => {
+      return (
+        <div key={responsiveSizeForMap[0]} className="custom-control custom-radio custom-control-inline">
+          <input
+            type="radio"
+            className="custom-control-input"
+            id={responsiveSizeForMap[1].displayText}
+            value={responsiveSizeForMap[1].displayText}
+            checked={this.state.responsiveSize === responsiveSizeForMap[0]}
+            onChange={e => this.checkResposiveSize(responsiveSizeForMap[0])}
+          />
+          <label className="custom-control-label" htmlFor={responsiveSizeForMap[1].displayText}>
+            <i className={`pr-1 ${responsiveSizeForMap[1].iconClass}`} />
+            {t(responsiveSizeForMap[1].displayText)}
+          </label>
+        </div>
+      );
+    });
+    return output;
+  }
+
+  renderGridDivisionMenu() {
+    const gridDivisions = geu.mappingAllGridDivisionPatterns;
+    const { t } = this.props;
+    return (
+      <div className="container">
+        <div className="row">
+          {gridDivisions.map((gridDivision) => {
+            const numOfDivisions = gridDivision.numberOfGridDivisions;
+            return (
+              <div key={`${numOfDivisions}-divisions`} className="col-md-4 text-center">
+                <h6 className="dropdown-header">{numOfDivisions} {t('grid_edit.division')}</h6>
+                {gridDivision.mapping.map((gridOneDivision) => {
+                  const keyOfRow = `${numOfDivisions}-divisions-${gridOneDivision.join('-')}`;
+                  return (
+                    <button key={keyOfRow} className="dropdown-item" type="button" onClick={() => { this.checkColsRatios(gridOneDivision) }}>
+                      <div className="row">
+                        {gridOneDivision.map((god, i) => {
+                          const keyOfCol = `${keyOfRow}-${i}`;
+                          const className = `bg-info col-${god} border`;
+                          return <span key={keyOfCol} className={className}>{god}</span>;
+                        })}
+                      </div>
+                    </button>
+                  );
+                })}
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+  renderPreview() {
+    const { t } = this.props;
+    const isMdSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE;
+    const isXsSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE;
+    return (
+      <div className="row">
+        <div className="col-lg-3">
+          <label className="d-block mt-2"><i className="pr-2 icon-screen-smartphone"></i>{t('phone')}</label>
+          <div className="mobile-preview d-block">
+            {this.renderGridPreview(!isXsSelected)}
+          </div>
+        </div>
+        <div className="col-lg-3">
+          <label className="d-block mt-2"><i className="pr-2 icon-screen-tablet"></i>{t('tablet')}</label>
+          <div className="tablet-preview d-block">
+            {this.renderGridPreview(isMdSelected)}
+          </div>
+        </div>
+        <div className="col-lg-6">
+          <label className="d-block mt-2"><i className="pr-2 icon-screen-desktop"></i>{t('desktop')}</label>
+          <div className="desktop-preview d-block">
+            {this.renderGridPreview(false)}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderGridPreview(isBreakEnabled) {
+    const { colsRatios } = this.state;
+
+    const convertedHTML = colsRatios.map((colsRatio, i) => {
+      const ratio = isBreakEnabled ? 12 : colsRatio;
+      const key = `grid-preview-col-${i}`;
+      const className = `col-${ratio} border`;
+      return (
+        <div key={key} className={className}></div>
+      );
+    });
+    return (
+      <div className="row">{convertedHTML}</div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    return (
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className="grw-grid-edit-modal">
+        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
+          {t('grid_edit.create_bootstrap_4_grid')}
+        </ModalHeader>
+        <ModalBody className="container">
+          <div className="row">
+            <div className="col-12">
+              <h3 className="grw-modal-head">{t('grid_edit.grid_settings')}</h3>
+              <form className="form-group mb-0">
+                <div className="form-group row my-3">
+                  <label className="col-sm-3" htmlFor="gridPattern">
+                    {t('grid_edit.grid_pattern')}
+                  </label>
+                  <div className="col-sm-9">
+                    <button
+                      className="btn btn-outline-secondary dropdown-toggle"
+                      type="button"
+                      id="dropdownMenuButton"
+                      data-toggle="dropdown"
+                      aria-haspopup="true"
+                      aria-expanded="false"
+                    >
+                      {this.renderSelectedGridPattern()}
+                    </button>
+                    <div className="dropdown-menu grid-division-menu" aria-labelledby="dropdownMenuButton">
+                      {this.renderGridDivisionMenu()}
+                    </div>
+                  </div>
+                </div>
+                <div className="form-group row">
+                  <label className="col-sm-3" htmlFor="breakPoint">
+                    {t('grid_edit.break_point')}
+                  </label>
+                  <div className="col-sm-9">
+                    {this.renderBreakPointSetting()}
+                  </div>
+                </div>
+              </form>
+            </div>
+          </div>
+          <h3 className="grw-modal-head">{t('preview')}</h3>
+          {this.renderPreview()}
+        </ModalBody>
+        <ModalFooter className="grw-modal-footer">
+          <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.pasteCodedGrid}>
+              Done
+            </button>
+          </div>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+GridEditModal.propTypes = {
+  onSave: PropTypes.func,
+  t: PropTypes.func.isRequired,
+};
+export default withTranslation('translation', { withRef: true })(GridEditModal);

+ 145 - 0
src/client/js/components/PageEditor/GridEditorUtil.js

@@ -0,0 +1,145 @@
+/**
+ * Utility for grid editor
+ */
+class GridEditorUtil {
+
+  constructor() {
+    // https://regex101.com/r/7BN2fR/11
+    this.lineBeginPartOfGridRE = /^:::(\s.*)editable-row$/;
+    this.lineEndPartOfGridRE = /^:::$/;
+    this.mappingAllGridDivisionPatterns = [
+      {
+        numberOfGridDivisions: 2,
+        mapping: [[2, 10], [4, 8], [6, 6], [8, 4], [10, 2]],
+      },
+      {
+        numberOfGridDivisions: 3,
+        mapping: [[2, 5, 5], [5, 2, 5], [5, 5, 2], [4, 4, 4], [3, 3, 6], [3, 6, 3], [6, 3, 3]],
+      },
+      {
+        numberOfGridDivisions: 4,
+        mapping: [[2, 2, 4, 4], [4, 4, 2, 2], [2, 4, 2, 4], [4, 2, 4, 2], [3, 3, 3, 3], [2, 2, 2, 6], [6, 2, 2, 2]],
+      },
+    ];
+    this.isInGridBlock = this.isInGridBlock.bind(this);
+    this.replaceGridWithHtmlWithEditor = this.replaceGridWithHtmlWithEditor.bind(this);
+  }
+
+  /**
+   * return boolean value whether the cursor position is in a grid block
+   */
+  isInGridBlock(editor) {
+    const bog = this.getBog(editor);
+    const eog = this.getEog(editor);
+    return (JSON.stringify(bog) !== JSON.stringify(eog));
+  }
+
+  /**
+   * return grid html where the cursor is
+   */
+  getGridHtml(editor) {
+    const curPos = editor.getCursor();
+
+    if (this.isInGridBlock(editor)) {
+      const bog = this.getBog(editor);
+      const eog = this.getEog(editor);
+      // skip block begin sesion("::: editable-row")
+      bog.line++;
+      // skip block end sesion(":::")
+      eog.line--;
+      eog.ch = editor.getDoc().getLine(eog.line).length;
+      return editor.getDoc().getRange(bog, eog);
+    }
+    return editor.getDoc().getLine(curPos.line);
+  }
+
+  /**
+   * return the postion of the BOD(beginning of grid)
+   */
+  getBog(editor) {
+    const curPos = editor.getCursor();
+    const firstLine = editor.getDoc().firstLine();
+
+    if (this.lineBeginPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: 0 };
+    }
+
+    let line = curPos.line - 1;
+    let isFound = false;
+    for (; line >= firstLine; line--) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineBeginPartOfGridRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineEndPartOfGridRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const bodLine = Math.max(firstLine, line);
+    return { line: bodLine, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOD(end of grid)
+   */
+  getEog(editor) {
+    const curPos = editor.getCursor();
+    const lastLine = editor.getDoc().lastLine();
+
+    if (this.lineEndPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
+    }
+
+    let line = curPos.line + 1;
+    let isFound = false;
+    for (; line <= lastLine; line++) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineEndPartOfGridRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineBeginPartOfGridRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const eodLine = Math.min(line, lastLine);
+    const lineLength = editor.getDoc().getLine(eodLine).length;
+    return { line: eodLine, ch: lineLength };
+  }
+
+  replaceGridWithHtmlWithEditor(editor, grid) {
+    const curPos = editor.getCursor();
+    editor.getDoc().replaceRange(grid.toString(), this.getBog(editor), this.getEog(editor));
+    editor.getDoc().setCursor(curPos.line + 1, 2);
+  }
+
+  convertRatiosAndSizeToHTML(ratioNumbers, responsiveSize) {
+    const cols = ratioNumbers.map((ratioNumber, i) => {
+      const spaceTab = '    ';
+      const className = `col${responsiveSize !== 'xs' ? `-${responsiveSize}` : ''}-${ratioNumber} bsGrid${i + 1}`;
+      return `${spaceTab}${spaceTab}<div class="${className}">Content</div>`;
+    });
+    return cols.join('\n');
+  }
+
+}
+
+// singleton pattern
+const instance = new GridEditorUtil();
+Object.freeze(instance);
+export default instance;

+ 34 - 0
src/client/js/models/BootstrapGrid.js

@@ -0,0 +1,34 @@
+export default class BootstrapGrid {
+
+  constructor(colsRatios, responsiveSize) {
+    this.colsRatios = BootstrapGrid.validateColsRatios(colsRatios);
+    this.responsiveSize = BootstrapGrid.validateResponsiveSize(responsiveSize);
+  }
+
+  static ResponsiveSize = {
+    XS_SIZE: 'xs', SM_SIZE: 'sm', MD_SIZE: 'md',
+  }
+
+  static validateColsRatios(colsRatios) {
+
+    if (colsRatios.length < 2 || colsRatios.length > 4) {
+      throw new Error('Incorrect array length of cols ratios');
+    }
+    const ratiosTotal = colsRatios.reduce((total, ratio) => { return total + ratio }, 0);
+    if (ratiosTotal !== 12) {
+      throw new Error('Incorrect cols ratios value');
+    }
+
+    return colsRatios;
+  }
+
+  static validateResponsiveSize(responsiveSize) {
+    if (responsiveSize === this.ResponsiveSize.XS_SIZE
+      || responsiveSize === this.ResponsiveSize.SM_SIZE
+      || responsiveSize === this.ResponsiveSize.MD_SIZE) {
+      return responsiveSize;
+    }
+    throw new Error('Incorrect responsive size');
+  }
+
+}

+ 2 - 0
src/client/js/util/GrowiRenderer.js

@@ -2,6 +2,7 @@ import MarkdownIt from 'markdown-it';
 
 
 import Linker from './PreProcessor/Linker';
 import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import CsvToTable from './PreProcessor/CsvToTable';
+import EasyGrid from './PreProcessor/EasyGrid';
 import XssFilter from './PreProcessor/XssFilter';
 import XssFilter from './PreProcessor/XssFilter';
 
 
 import EmojiConfigurer from './markdown-it/emoji';
 import EmojiConfigurer from './markdown-it/emoji';
@@ -37,6 +38,7 @@ export default class GrowiRenderer {
     }
     }
     else {
     else {
       this.preProcessors = [
       this.preProcessors = [
+        new EasyGrid(appContainer),
         new Linker(appContainer),
         new Linker(appContainer),
         new CsvToTable(appContainer),
         new CsvToTable(appContainer),
         new XssFilter(appContainer),
         new XssFilter(appContainer),

+ 10 - 0
src/client/js/util/PreProcessor/EasyGrid.js

@@ -0,0 +1,10 @@
+export default class EasyGrid {
+
+  process(markdown) {
+    // see: https://regex101.com/r/7NWvUU/2
+    return markdown.replace(/:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group) => {
+      return group;
+    });
+  }
+
+}

+ 3 - 0
src/client/styles/scss/_editor-navbar.scss

@@ -7,6 +7,9 @@
 
 
     li {
     li {
       display: inline-block;
       display: inline-block;
+      i {
+        font-size: 16px;
+      }
     }
     }
 
 
     button {
     button {

+ 49 - 0
src/client/styles/scss/_on-edit.scss

@@ -328,3 +328,52 @@ body.on-edit {
     border-bottom: 5px solid $gray-300;
     border-bottom: 5px solid $gray-300;
   }
   }
 }
 }
+
+/*
+ Grid Edit Modal
+*/
+
+.grw-grid-edit-modal {
+  .desktop-preview,
+  .tablet-preview,
+  .mobile-preview {
+    .row {
+      height: 140px;
+      margin: 0px;
+    }
+  }
+  .desktop-preview {
+    .row {
+      div {
+        padding: 0px;
+        background-color: $info;
+      }
+    }
+  }
+
+  .tablet-preview {
+    .row {
+      div {
+        padding: 0px;
+        background-color: $purple;
+      }
+    }
+  }
+
+  .mobile-preview {
+    width: 75%;
+    .row {
+      div {
+        padding: 0px;
+        background-color: $pink;
+      }
+    }
+  }
+
+  .grid-division-menu {
+    width: 60vw;
+    @include media-breakpoint-down(lg) {
+      width: 80vw;
+    }
+  }
+}