Browse Source

Merge branch 'master' into feat/enhanced-link-edit-modal-for-master-merge

itizawa 5 years ago
parent
commit
04612a3bea
35 changed files with 731 additions and 271 deletions
  1. 2 0
      CHANGES.md
  2. 1 0
      resource/locales/en_US/translation.json
  3. 1 0
      resource/locales/ja_JP/translation.json
  4. 3 3
      src/client/js/base.jsx
  5. 80 0
      src/client/js/components/Hotkeys/HotkeysDetector.jsx
  6. 79 0
      src/client/js/components/Hotkeys/HotkeysManager.jsx
  7. 31 0
      src/client/js/components/Hotkeys/Subscribers/CreatePage.jsx
  8. 30 0
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  9. 26 0
      src/client/js/components/Hotkeys/Subscribers/ShowShortcutsModal.jsx
  10. 20 0
      src/client/js/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  11. 25 0
      src/client/js/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  12. 13 13
      src/client/js/components/InstallerForm.jsx
  13. 8 3
      src/client/js/components/Page/RevisionRenderer.jsx
  14. 42 57
      src/client/js/components/StaffCredit/StaffCredit.jsx
  15. 12 3
      src/client/js/components/TableOfContents.jsx
  16. 0 40
      src/client/js/legacy/crowi.js
  17. 57 0
      src/client/js/models/HotkeyStroke.js
  18. 16 20
      src/client/js/services/NavigationContainer.js
  19. 3 0
      src/client/styles/scss/_mirror_mode.scss
  20. 9 11
      src/client/styles/scss/_shortcuts.scss
  21. 15 8
      src/client/styles/scss/_staff_credit.scss
  22. 1 0
      src/client/styles/scss/style-app.scss
  23. 24 9
      src/server/crowi/index.js
  24. 42 47
      src/server/models/page.js
  25. 1 1
      src/server/models/vo/config-pubsub-message.js
  26. 1 1
      src/server/routes/index.js
  27. 4 5
      src/server/routes/installer.js
  28. 64 4
      src/server/service/app.js
  29. 6 0
      src/server/service/config-loader.js
  30. 16 0
      src/server/service/config-pubsub/base.js
  31. 21 9
      src/server/service/config-pubsub/nchan.js
  32. 7 6
      src/server/service/customize.js
  33. 1 2
      src/server/views/layout/layout.html
  34. 16 14
      src/server/views/modal/shortcuts.html
  35. 54 15
      src/test/models/page.test.js

+ 2 - 0
CHANGES.md

@@ -12,6 +12,8 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 ### Updates
 
 * Feature: Config synchronization for multiple GROWI Apps
+* Feature: Smooth scroll for anchor links
+* Feature: Mirror Mode with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
 * Improvement: Determine whether the "In Use" badge is displayed or not by attachment ID
 * Fix: New settings of SMTP and AWS SES are not reflected when server is running
 * Support: Support Node.js v14

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

@@ -318,6 +318,7 @@
       "Edit Page": "Edit Page",
       "Create Page": "Create Page",
       "Show Contributors": "Show Contributors",
+      "MirrorMode": "Mirror Mode",
       "Konami Code": "Konami Code",
       "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },

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

@@ -319,6 +319,7 @@
       "Edit Page": "ページ編集",
       "Create Page": "ページ作成",
       "Show Contributors": "コントリビューター<br>を表示",
+      "MirrorMode": "ミラーモード",
       "Konami Code": "コナミコマンド",
       "konami_code_url": "https://ja.wikipedia.org/wiki/コナミコマンド"
     },

+ 3 - 3
src/client/js/base.jsx

@@ -6,8 +6,8 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import HotkeysManager from './components/Hotkeys/HotkeysManager';
 import Fab from './components/Fab';
-import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
@@ -45,9 +45,9 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'grw-fab-container': <Fab />,
+  'grw-hotkeys-manager': <HotkeysManager />,
 
-  'staff-credit': <StaffCredit />,
+  'grw-fab-container': <Fab />,
 };
 
 export { appContainer, componentMappings };

+ 80 - 0
src/client/js/components/Hotkeys/HotkeysDetector.jsx

@@ -0,0 +1,80 @@
+import React, { useMemo, useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { GlobalHotKeys } from 'react-hotkeys';
+
+import HotkeyStroke from '../../models/HotkeyStroke';
+
+const HotkeysDetector = (props) => {
+
+  const { keySet, strokeSet, onDetected } = props;
+
+  // memorize HotkeyStroke instances
+  const hotkeyStrokes = useMemo(
+    () => {
+      const strokes = Array.from(strokeSet);
+      return strokes.map(stroke => new HotkeyStroke(stroke));
+    },
+    [strokeSet],
+  );
+
+  /**
+   * return key expression string includes modifier
+   */
+  const getKeyExpression = useCallback((event) => {
+    let eventKey = event.key;
+
+    if (event.ctrlKey) {
+      eventKey += '+ctrl';
+    }
+    if (event.metaKey) {
+      eventKey += '+meta';
+    }
+    if (event.altKey) {
+      eventKey += '+alt';
+    }
+    if (event.shiftKey) {
+      eventKey += '+shift';
+    }
+
+    return eventKey;
+  }, []);
+
+  /**
+   * evaluate the key user pressed and trigger onDetected
+   */
+  const checkHandler = useCallback((event) => {
+    event.preventDefault();
+
+    const eventKey = getKeyExpression(event);
+
+    hotkeyStrokes.forEach((hotkeyStroke) => {
+      if (hotkeyStroke.evaluate(eventKey)) {
+        onDetected(hotkeyStroke.stroke);
+      }
+    });
+  }, [hotkeyStrokes, getKeyExpression, onDetected]);
+
+  // memorize keyMap for GlobalHotKeys
+  const keyMap = useMemo(() => {
+    return { check: Array.from(keySet) };
+  }, [keySet]);
+
+  // memorize handlers for GlobalHotKeys
+  const handlers = useMemo(() => {
+    return { check: checkHandler };
+  }, [checkHandler]);
+
+  return (
+    <GlobalHotKeys keyMap={keyMap} handlers={handlers} />
+  );
+
+};
+
+HotkeysDetector.propTypes = {
+  onDetected: PropTypes.func.isRequired,
+  keySet: PropTypes.instanceOf(Set).isRequired,
+  strokeSet: PropTypes.instanceOf(Set).isRequired,
+};
+
+export default HotkeysDetector;

+ 79 - 0
src/client/js/components/Hotkeys/HotkeysManager.jsx

@@ -0,0 +1,79 @@
+import React, { useState } from 'react';
+
+import HotkeysDetector from './HotkeysDetector';
+
+import ShowStaffCredit from './Subscribers/ShowStaffCredit';
+import SwitchToMirrorMode from './Subscribers/SwitchToMirrorMode';
+import ShowShortcutsModal from './Subscribers/ShowShortcutsModal';
+import CreatePage from './Subscribers/CreatePage';
+import EditPage from './Subscribers/EditPage';
+
+// define supported components list
+const SUPPORTED_COMPONENTS = [
+  ShowStaffCredit,
+  SwitchToMirrorMode,
+  ShowShortcutsModal,
+  CreatePage,
+  EditPage,
+];
+
+const KEY_SET = new Set();
+const STROKE_SET = new Set();
+const STROKE_TO_COMPONENT_MAP = {};
+
+SUPPORTED_COMPONENTS.forEach((comp) => {
+  const strokes = comp.getHotkeyStrokes();
+
+  strokes.forEach((stroke) => {
+    // register key
+    stroke.forEach(key => KEY_SET.add(key));
+    // register stroke
+    STROKE_SET.add(stroke);
+    // register component
+    const componentList = STROKE_TO_COMPONENT_MAP[stroke] || [];
+    componentList.push(comp);
+    STROKE_TO_COMPONENT_MAP[stroke.toString()] = componentList;
+  });
+});
+
+const HotkeysManager = (props) => {
+  const [view, setView] = useState([]);
+
+  /**
+   * delete the instance in state.view
+   */
+  const deleteRender = (instance) => {
+    const index = view.lastIndexOf(instance);
+
+    const newView = view.slice(); // shallow copy
+    newView.splice(index, 1);
+    setView(newView);
+  };
+
+  /**
+   * activates when one of the hotkey strokes gets determined from HotkeysDetector
+   */
+  const onDetected = (strokeDetermined) => {
+    const key = (Math.random() * 1000).toString();
+    const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
+
+    const newViews = components.map(Component => (
+      <Component key={key} onDeleteRender={deleteRender} />
+    ));
+    setView(view.concat(newViews).flat());
+  };
+
+  return (
+    <>
+      <HotkeysDetector
+        onDetected={stroke => onDetected(stroke)}
+        keySet={KEY_SET}
+        strokeSet={STROKE_SET}
+      />
+      {view}
+    </>
+  );
+
+};
+
+export default HotkeysManager;

+ 31 - 0
src/client/js/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -0,0 +1,31 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+import NavigationContainer from '../../../services/NavigationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const CreatePage = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    props.navigationContainer.openPageCreateModal();
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+CreatePage.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
+
+CreatePageWrapper.getHotkeyStrokes = () => {
+  return [['c']];
+};
+
+export default CreatePageWrapper;

+ 30 - 0
src/client/js/components/Hotkeys/Subscribers/EditPage.jsx

@@ -0,0 +1,30 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const EditPage = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    // ignore when dom that has 'modal in' classes exists
+    if (document.getElementsByClassName('modal in').length > 0) {
+      return;
+    }
+    // show editor
+    $('a[data-toggle="tab"][href="#edit"]').tab('show');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+EditPage.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+EditPage.getHotkeyStrokes = () => {
+  return [['e']];
+};
+
+export default EditPage;

+ 26 - 0
src/client/js/components/Hotkeys/Subscribers/ShowShortcutsModal.jsx

@@ -0,0 +1,26 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const ShowShortcutsModal = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    // show modal to create a page
+    $('#shortcuts-modal').modal('toggle');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+ShowShortcutsModal.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+ShowShortcutsModal.getHotkeyStrokes = () => {
+  return [['/+ctrl'], ['/+meta']];
+};
+
+export default ShowShortcutsModal;

+ 20 - 0
src/client/js/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import StaffCredit from '../../StaffCredit/StaffCredit';
+
+const ShowStaffCredit = (props) => {
+
+  return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
+
+};
+
+ShowStaffCredit.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+ShowStaffCredit.getHotkeyStrokes = () => {
+  return [['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']];
+};
+
+export default ShowStaffCredit;

+ 25 - 0
src/client/js/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -0,0 +1,25 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const SwitchToMirrorMode = (props) => {
+
+  // setup effect
+  useEffect(() => {
+    document.body.classList.add('mirror');
+
+    // remove this
+    props.onDeleteRender(this);
+  }, [props]);
+
+  return <></>;
+};
+
+SwitchToMirrorMode.propTypes = {
+  onDeleteRender: PropTypes.func.isRequired,
+};
+
+SwitchToMirrorMode.getHotkeyStrokes = () => {
+  return [['x', 'x', 'b', 'b', 'a', 'y', 'a', 'y', 'ArrowDown', 'ArrowLeft']];
+};
+
+export default SwitchToMirrorMode;

+ 13 - 13
src/client/js/components/InstallerForm.jsx

@@ -15,24 +15,24 @@ class InstallerForm extends React.Component {
       isValidUserName: true,
       selectedLang: {},
     };
-    this.checkUserName = this.checkUserName.bind(this);
+    // this.checkUserName = this.checkUserName.bind(this);
   }
 
   componentWillMount() {
     this.changeLanguage(localeMetadatas[0]);
   }
 
-  checkUserName(event) {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    axios.get('/_api/check_username', { params: { username: event.target.value } })
-      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
-  }
+  // checkUserName(event) {
+  //   const axios = require('axios').create({
+  //     headers: {
+  //       'Content-Type': 'application/json',
+  //       'X-Requested-With': 'XMLHttpRequest',
+  //     },
+  //     responseType: 'json',
+  //   });
+  //   axios.get('/_api/check_username', { params: { username: event.target.value } })
+  //     .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
+  // }
 
   changeLanguage(meta) {
     i18next.changeLanguage(meta.id);
@@ -94,7 +94,7 @@ class InstallerForm extends React.Component {
                 placeholder={this.props.t('User ID')}
                 name="registerForm[username]"
                 defaultValue={this.props.userName}
-                onBlur={this.checkUserName}
+                // onBlur={this.checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
                 required
               />
             </div>

+ 8 - 3
src/client/js/components/Page/RevisionRenderer.jsx

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 import RevisionBody from './RevisionBody';
@@ -35,7 +36,7 @@ class RevisionRenderer extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords } = this.props;
+    const { markdown, highlightKeywords, navigationContainer } = this.props;
 
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -44,6 +45,10 @@ class RevisionRenderer extends React.PureComponent {
       return;
     }
 
+    const HeaderLink = document.getElementsByClassName('revision-head-link');
+    const HeaderLinkArray = Array.from(HeaderLink);
+    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+
     const { interceptorManager } = this.props.appContainer;
 
     interceptorManager.process('postRenderHtml', this.currentRenderingContext);
@@ -115,12 +120,12 @@ class RevisionRenderer extends React.PureComponent {
 /**
  * Wrapper component for using unstated
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, PageContainer]);
+const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, PageContainer, NavigationContainer]);
 
 RevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,

+ 42 - 57
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -1,16 +1,11 @@
 import React from 'react';
-import { GlobalHotKeys } from 'react-hotkeys';
-
+import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
-
 import contributors from './Contributor';
 
-// px / sec
-const scrollSpeed = 200;
-
 /**
  * Page staff credit component
  *
@@ -19,56 +14,21 @@ const scrollSpeed = 200;
  * @extends {React.Component}
  */
 
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:cli:StaffCredit');
+
 export default class StaffCredit extends React.Component {
 
   constructor(props) {
-    super(props);
-
-    this.logger = loggerFactory('growi:StaffCredit');
 
+    super(props);
     this.state = {
-      isShown: false,
-      userCommand: [],
+      isShown: true,
     };
-    this.konamiCommand = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
     this.deleteCredit = this.deleteCredit.bind(this);
   }
 
-  check(event) {
-    this.logger.debug(`'${event.key}' pressed`);
-
-    // compare keydown and next konamiCommand
-    if (this.konamiCommand[this.state.userCommand.length] === event.key) {
-      const nextValue = this.state.userCommand.concat(event.key);
-      if (nextValue.length === this.konamiCommand.length) {
-        this.setState({
-          isShown: true,
-          userCommand: [],
-        });
-        const target = $('.credit-curtain');
-        const scrollTargetHeight = target.children().innerHeight();
-        const duration = scrollTargetHeight / scrollSpeed * 1000;
-        target.animate({ scrollTop: scrollTargetHeight }, duration, 'linear');
-
-        target.slimScroll({
-          height: target.innerHeight(),
-          // Able to scroll after automatic schooling is complete so set "bottom" to allow scrolling from the bottom.
-          start: 'bottom',
-          color: '#FFFFFF',
-        });
-      }
-      else {
-        // add UserCommand
-        this.setState({ userCommand: nextValue });
-
-        this.logger.debug('userCommand', this.state.userCommand);
-      }
-    }
-    else {
-      this.setState({ userCommand: [] });
-    }
-  }
-
+  // to delete the staffCredit and to inform that to Hotkeys.jsx
   deleteCredit() {
     if (this.state.isShown) {
       this.setState({ isShown: false });
@@ -123,21 +83,46 @@ export default class StaffCredit extends React.Component {
     return null;
   }
 
+  componentDidMount() {
+    setTimeout(() => {
+      // px / sec
+      const scrollSpeed = 200;
+      const target = $('.credit-curtain');
+      const scrollTargetHeight = target.children().innerHeight();
+      const duration = scrollTargetHeight / scrollSpeed * 1000;
+      target.animate({ scrollTop: scrollTargetHeight }, duration, 'linear');
+      target.slimScroll({
+        height: target.innerHeight(),
+        // Able to scroll after automatic schooling is complete so set "bottom" to allow scrolling from the bottom.
+        start: 'bottom',
+        color: '#FFFFFF',
+      });
+    }, 10);
+  }
+
   render() {
-    const keyMap = { check: ['up', 'down', 'right', 'left', 'b', 'a'] };
-    const handlers = { check: (event) => { return this.check(event) } };
+    const { onClosed } = this.props;
+
     return (
-      <GlobalHotKeys keyMap={keyMap} handlers={handlers}>
-        <Modal isOpen={this.state.isShown} toggle={this.deleteCredit} scrollable className="staff-credit">
-          <ModalBody className="credit-curtain">
-            {this.renderContributors()}
-          </ModalBody>
-        </Modal>
-      </GlobalHotKeys>
+      <Modal
+        isOpen={this.state.isShown}
+        onClosed={() => {
+          if (onClosed != null) {
+            onClosed();
+          }
+        }}
+        toggle={this.deleteCredit}
+        scrollable
+        className="staff-credit"
+      >
+        <ModalBody className="credit-curtain">
+          {this.renderContributors()}
+        </ModalBody>
+      </Modal>
     );
   }
 
 }
-
 StaffCredit.propTypes = {
+  onClosed: PropTypes.func,
 };

+ 12 - 3
src/client/js/components/TableOfContents.jsx

@@ -1,10 +1,11 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withTranslation } from 'react-i18next';
 
 import PageContainer from '../services/PageContainer';
+import NavigationContainer from '../services/NavigationContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import StickyStretchableScroller from './StickyStretchableScroller';
@@ -18,7 +19,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer } = props;
+  const { pageContainer, navigationContainer } = props;
 
   const calcViewHeight = useCallback(() => {
     // calculate absolute top of '#revision-toc' element
@@ -31,6 +32,13 @@ const TableOfContents = (props) => {
 
   const { tocHtml } = pageContainer.state;
 
+  // execute after generation toc html
+  useEffect(() => {
+    const tocDom = document.getElementById('revision-toc-content');
+    const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
+    navigationContainer.addSmoothScrollEvent(anchorsInToc);
+  }, [tocHtml, navigationContainer]);
+
   return (
     <>
       {/* TODO GW-3253 add four contents */}
@@ -56,10 +64,11 @@ const TableOfContents = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
 
 TableOfContents.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

+ 0 - 40
src/client/js/legacy/crowi.js

@@ -85,22 +85,6 @@ Crowi.modifyScrollTop = function() {
   }, timeout);
 };
 
-Crowi.handleKeyEHandler = (event) => {
-  // ignore when dom that has 'modal in' classes exists
-  if (document.getElementsByClassName('modal in').length > 0) {
-    return;
-  }
-  // show editor
-  $('a[data-toggle="tab"][href="#edit"]').tab('show');
-  event.preventDefault();
-};
-
-Crowi.handleKeyCtrlSlashHandler = (event) => {
-  // show modal to create a page
-  $('#shortcuts-modal').modal('toggle');
-  event.preventDefault();
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -410,30 +394,6 @@ window.addEventListener('hashchange', (e) => {
   }
 });
 
-window.addEventListener('keydown', (event) => {
-  const target = event.target;
-
-  // ignore when target dom is input
-  const inputPattern = /^input|textinput|textarea$/i;
-  if (inputPattern.test(target.tagName) || target.isContentEditable) {
-    return;
-  }
-
-  switch (event.key) {
-    case 'e':
-      if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-        Crowi.handleKeyEHandler(event);
-      }
-      break;
-    case '/':
-      if (event.ctrlKey || event.metaKey) {
-        Crowi.handleKeyCtrlSlashHandler(event);
-      }
-      break;
-    default:
-  }
-});
-
 // adjust min-height of page for print temporarily
 window.onbeforeprint = function() {
   $('#page-wrapper').css('min-height', '0px');

+ 57 - 0
src/client/js/models/HotkeyStroke.js

@@ -0,0 +1,57 @@
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:cli:HotkeyStroke');
+
+export default class HotkeyStroke {
+
+  constructor(stroke) {
+    this.stroke = stroke;
+    this.activeIndices = [];
+  }
+
+  get firstKey() {
+    return this.stroke[0];
+  }
+
+  /**
+   * Evaluate whether the specified key completes stroke or not
+   * @param {string} key
+   * @return T/F whether the specified key completes stroke or not
+   */
+  evaluate(key) {
+    if (key === this.firstKey) {
+      // add a new active index
+      this.activeIndices.push(0);
+    }
+
+    let isCompleted = false;
+    this.activeIndices = this.activeIndices
+      .map((index) => {
+        // return null when key does not match
+        if (key !== this.stroke[index]) {
+          return null;
+        }
+
+        const nextIndex = index + 1;
+
+        if (this.stroke.length <= nextIndex) {
+          isCompleted = true;
+          return null;
+        }
+
+        return nextIndex;
+      })
+      // exclude null
+      .filter(index => index != null);
+
+    // reset if completed
+    if (isCompleted) {
+      this.activeIndices = [];
+    }
+
+    logger.debug('activeIndices for [', this.stroke, '] => [', this.activeIndices, ']');
+
+    return isCompleted;
+  }
+
+}

+ 16 - 20
src/client/js/services/NavigationContainer.js

@@ -6,6 +6,7 @@ import { Container } from 'unstated';
  */
 
 const SCROLL_THRES_SKIP = 200;
+const WIKI_HEADER_LINK = 120;
 
 export default class NavigationContainer extends Container {
 
@@ -36,8 +37,6 @@ export default class NavigationContainer extends Container {
 
     this.openPageCreateModal = this.openPageCreateModal.bind(this);
     this.closePageCreateModal = this.closePageCreateModal.bind(this);
-
-    this.initHotkeys();
     this.initDeviceSize();
     this.initScrollEvent();
   }
@@ -49,24 +48,6 @@ export default class NavigationContainer extends Container {
     return 'NavigationContainer';
   }
 
-  initHotkeys() {
-    window.addEventListener('keydown', (event) => {
-      const target = event.target;
-
-      // ignore when target dom is input
-      const inputPattern = /^input|textinput|textarea$/i;
-      if (inputPattern.test(target.tagName) || target.isContentEditable) {
-        return;
-      }
-
-      if (event.key === 'c') {
-        // don't fire when not needed
-        if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
-          this.setState({ isPageCreateModalShown: true });
-        }
-      }
-    });
-  }
 
   initDeviceSize() {
     const mdOrAvobeHandler = async(mql) => {
@@ -171,6 +152,21 @@ export default class NavigationContainer extends Container {
     this.setState({ isPageCreateModalShown: false });
   }
 
+  /**
+   * Function that implements the click event for realizing smooth scroll
+   * @param {array} elements
+   */
+  addSmoothScrollEvent(elements = {}) {
+    elements.forEach(link => link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      const href = link.getAttribute('href').replace('#', '');
+      window.location.hash = href;
+      const targetDom = document.getElementById(href);
+      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+    }));
+  }
+
   smoothScrollIntoView(element = null, offsetTop = 0) {
     const targetElement = element || window.document.body;
 

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

@@ -0,0 +1,3 @@
+body.mirror {
+  transform: scale(-1, 1);
+}

+ 9 - 11
src/client/styles/scss/_shortcuts.scss

@@ -1,15 +1,4 @@
 #shortcuts-modal {
-  @include media-breakpoint-only(xs) {
-    .modal-dialog {
-      width: 750px;
-    }
-  }
-  @include media-breakpoint-up(sm) {
-    table {
-      table-layout: fixed;
-    }
-  }
-
   h3 {
     margin-bottom: 1em;
   }
@@ -23,6 +12,15 @@
     }
   }
 
+  @include media-breakpoint-up(sm) {
+    table {
+      table-layout: fixed;
+      th {
+        width: 170px;
+      }
+    }
+  }
+
   // see http://coliss.com/articles/build-websites/operation/css/css-apple-keyboard-style-by-nrjmadan.html
   .key {
     /*Box Properties*/

+ 15 - 8
src/client/styles/scss/_staff_credit.scss

@@ -10,30 +10,37 @@
   margin: 10vh 10vw !important;
 
   // see https://css-tricks.com/old-timey-terminal-styling/
-  @mixin old-timey-terminal-styling() {
+  .credit-curtain {
+    padding-top: 80vh;
+
     text-shadow: 0 0 10px #c8c8c8;
     background-color: black;
     background-image: radial-gradient(rgba(50, 100, 100, 0.75), black 120%);
   }
+  &::after {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    content: '';
+    background: repeating-linear-gradient(0deg, rgba(black, 0.15), rgba(black, 0.15) 2px, transparent 2px, transparent 4px);
+  }
 
   h1,
   h2,
   h3,
   h4,
   h5,
-  h6 {
+  h6,
+  .dev-position,
+  .dev-name {
     font-family: 'Press Start 2P', $font-family-for-staff-credit;
     color: white;
   }
 
   $credit-length: -240em;
 
-  // see https://css-tricks.com/old-timey-terminal-styling/
-  .credit-curtain {
-    padding-top: 80vh;
-    @include old-timey-terminal-styling();
-  }
-
   h1 {
     font-size: 3em;
   }

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -42,6 +42,7 @@
 @import 'layout_kibela';
 @import 'login';
 @import 'me';
+@import 'mirror_mode';
 @import 'navbar';
 @import 'navbar_kibela';
 @import 'notification';

+ 24 - 9
src/server/crowi/index.js

@@ -35,6 +35,8 @@ function Crowi(rootdir) {
   this.tmpDir = path.join(this.rootDir, 'tmp') + sep;
   this.cacheDir = path.join(this.tmpDir, 'cache');
 
+  this.express = null;
+
   this.config = {};
   this.configManager = null;
   this.mailService = null;
@@ -378,7 +380,9 @@ Crowi.prototype.start = async function() {
   }
 
   await this.init();
-  const express = await this.buildServer();
+  await this.buildServer();
+
+  const { express } = this;
 
   // setup plugins
   this.pluginService = new PluginService(this, express);
@@ -401,14 +405,14 @@ Crowi.prototype.start = async function() {
   this.io = io;
 
   // setup Express Routes
-  this.setupRoutesAtLast(express);
+  this.setupRoutesAtLast();
 
   return serverListening;
 };
 
-Crowi.prototype.buildServer = function() {
-  const express = require('express')();
+Crowi.prototype.buildServer = async function() {
   const env = this.node_env;
+  const express = require('express')();
 
   require('./express-init')(this, express);
 
@@ -427,15 +431,20 @@ Crowi.prototype.buildServer = function() {
     express.use(morgan('dev'));
   }
 
-  return Promise.resolve(express);
+  this.express = express;
 };
 
 /**
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!
  */
-Crowi.prototype.setupRoutesAtLast = function(app) {
-  require('../routes')(this, app);
+Crowi.prototype.setupRoutesAtLast = function() {
+  require('../routes')(this, this.express);
+};
+
+Crowi.prototype.setupAfterInstall = function() {
+  this.pluginService.autoDetectAndLoadPlugins();
+  this.setupRoutesAtLast();
 };
 
 /**
@@ -496,7 +505,7 @@ Crowi.prototype.setUpAcl = async function() {
 Crowi.prototype.setUpCustomize = async function() {
   const CustomizeService = require('../service/customize');
   if (this.customizeService == null) {
-    this.customizeService = new CustomizeService(this.configManager, this.appService, this.xssService, this.configPubsub);
+    this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomTitle();
 
@@ -513,7 +522,13 @@ Crowi.prototype.setUpCustomize = async function() {
 Crowi.prototype.setUpApp = async function() {
   const AppService = require('../service/app');
   if (this.appService == null) {
-    this.appService = new AppService(this.configManager);
+    this.appService = new AppService(this);
+
+    // add as a message handler
+    const isInstalled = this.configManager.getConfig('crowi', 'app:installed');
+    if (this.configPubsub != null && !isInstalled) {
+      this.configPubsub.addMessageHandler(this.appService);
+    }
   }
 };
 

+ 42 - 47
src/server/models/page.js

@@ -96,14 +96,6 @@ const extractToAncestorsPaths = (pagePath) => {
   return ancestorsPaths;
 };
 
-const addSlashOfEnd = (path) => {
-  let returnPath = path;
-  if (!path.match(/\/$/)) {
-    returnPath += '/';
-  }
-  return returnPath;
-};
-
 /**
  * populate page (Query or Document) to show revision
  * @param {any} page Query or Document
@@ -148,15 +140,50 @@ class PageQueryBuilder {
   }
 
   /**
-   * generate the query to find the page that is match with `path` and its descendants
+   * generate the query to find the pages '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
    */
   addConditionToListWithDescendants(path, option) {
-    // ignore other pages than descendants
-    // eslint-disable-next-line no-param-reassign
-    path = addSlashOfEnd(path);
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathNormalized = pathUtils.normalizePath(path);
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({
+        $or: [
+          { path: pathNormalized },
+          { path: new RegExp(`^${startsPattern}`) },
+        ],
+      });
+
+    return this;
+  }
+
+  /**
+   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
+   * If top page, return without doing anything.
+   */
+  addConditionToListOnlyDescendants(path, option) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
 
-    this.addConditionToListByStartWith(path, option);
     return this;
+
   }
 
   /**
@@ -173,36 +200,11 @@ class PageQueryBuilder {
     if (isTopPage(path)) {
       return this;
     }
-    const pathCondition = [];
-
-    /*
-     * 1. add condition for finding the page completely match with `path` w/o last slash
-     */
-    let pathSlashOmitted = path;
-    if (path.match(/\/$/)) {
-      pathSlashOmitted = path.substr(0, path.length - 1);
-      pathCondition.push({ path: pathSlashOmitted });
-    }
-    /*
-     * 2. add decendants
-     */
-    const pattern = escapeStringRegexp(path); // escape
 
-    let queryReg;
-    try {
-      queryReg = new RegExp(`^${pattern}`);
-    }
-    // if regular expression is invalid
-    catch (e) {
-      // force to escape
-      queryReg = new RegExp(`^${escapeStringRegexp(pattern)}`);
-    }
-    pathCondition.push({ path: queryReg });
+    const startsPattern = escapeStringRegexp(path);
 
     this.query = this.query
-      .and({
-        $or: pathCondition,
-      });
+      .and({ path: new RegExp(`^${startsPattern}`) });
 
     return this;
   }
@@ -1386,13 +1388,6 @@ module.exports = function(crowi) {
 
   };
 
-  /**
-   * return path that added slash to the end for specified path
-   */
-  pageSchema.statics.addSlashOfEnd = function(path) {
-    return addSlashOfEnd(path);
-  };
-
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 1 - 1
src/server/models/vo/config-pubsub-message.js

@@ -1,6 +1,6 @@
 class ConfigPubsubMessage {
 
-  constructor(eventName, body) {
+  constructor(eventName, body = {}) {
     this.eventName = eventName;
     for (const [key, value] of Object.entries(body)) {
       this[key] = value;

+ 1 - 1
src/server/routes/index.js

@@ -20,7 +20,6 @@ module.exports = function(crowi, app) {
   const logout = require('./logout')(crowi, app);
   const me = require('./me')(crowi, app);
   const admin = require('./admin')(crowi, app);
-  const installer = require('./installer')(crowi, app);
   const user = require('./user')(crowi, app);
   const attachment = require('./attachment')(crowi, app);
   const comment = require('./comment')(crowi, app);
@@ -41,6 +40,7 @@ module.exports = function(crowi, app) {
 
   // installer
   if (!isInstalled) {
+    const installer = require('./installer')(crowi);
     app.get('/installer'               , applicationNotInstalled , installer.index);
     app.post('/installer'              , applicationNotInstalled , form.register , csrf, installer.install);
     return;

+ 4 - 5
src/server/routes/installer.js

@@ -1,4 +1,4 @@
-module.exports = function(crowi, app) {
+module.exports = function(crowi) {
   const logger = require('@alias/logger')('growi:routes:installer');
   const path = require('path');
   const fs = require('graceful-fs');
@@ -84,10 +84,9 @@ module.exports = function(crowi, app) {
     }
     // create initial pages
     await createInitialPages(adminUser, language);
-    // init plugins
-    crowi.pluginService.autoDetectAndLoadPlugins();
-    // setup routes
-    crowi.setupRoutesAtLast(app);
+
+    crowi.setupAfterInstall();
+    appService.publishPostInstallationMessage();
 
     // login with passport
     req.logIn(adminUser, (err) => {

+ 64 - 4
src/server/service/app.js

@@ -1,13 +1,73 @@
 const logger = require('@alias/logger')('growi:service:AppService'); // eslint-disable-line no-unused-vars
 const { pathUtils } = require('growi-commons');
 
+
+const ConfigPubsubMessage = require('../models/vo/config-pubsub-message');
+const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
+
 /**
  * the service class of AppService
  */
-class AppService {
+class AppService extends ConfigPubsubMessageHandlable {
+
+  constructor(crowi) {
+    super();
+
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleConfigPubsubMessage(configPubsubMessage) {
+    const { eventName } = configPubsubMessage;
+    if (eventName !== 'systemInstalled') {
+      return false;
+    }
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+
+    return !isInstalled;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleConfigPubsubMessage(configPubsubMessage) {
+    logger.info('Invoke post installation process by pubsub notification');
+
+    const { crowi, configManager, configPubsub } = this;
+
+    // load config and setup
+    await configManager.loadConfigs();
+
+    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
+    if (isInstalled) {
+      crowi.setupAfterInstall();
+
+      // remove message handler
+      configPubsub.removeMessageHandler(this);
+    }
+  }
+
+  async publishPostInstallationMessage() {
+    const { configPubsub } = this;
+
+    if (configPubsub != null) {
+      const configPubsubMessage = new ConfigPubsubMessage('systemInstalled');
+
+      try {
+        await configPubsub.publish(configPubsubMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish post installation message with configPubsub: ', e.message);
+      }
+    }
 
-  constructor(configManager) {
-    this.configManager = configManager;
+    // remove message handler
+    configPubsub.removeMessageHandler(this);
   }
 
   getAppTitle() {
@@ -49,7 +109,7 @@ class AppService {
   async initDB(globalLang) {
     const initialConfig = this.configManager.configModel.getConfigsObjectForInstalling();
     initialConfig['app:globalLang'] = globalLang;
-    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig);
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
   async isDBInitialized() {

+ 6 - 0
src/server/service/config-loader.js

@@ -143,6 +143,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: '/pubsub',
   },
+  CONFIG_PUBSUB_NCHAN_CHANNEL_ID: {
+    ns:      'crowi',
+    key:     'configPubsub:nchan:channelId',
+    type:    TYPES.STRING,
+    default: null,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 16 - 0
src/server/service/config-pubsub/base.js

@@ -39,6 +39,22 @@ class ConfigPubsubDelegator {
       logger.debug('Unsupported instance: ', handlable);
       return;
     }
+
+    this.handlableList.push(handlable);
+  }
+
+  /**
+   * Remove message handler
+   * @param {ConfigPubsubMessageHandlable} handlable
+   */
+  removeMessageHandler(handlable) {
+    if (!(handlable instanceof ConfigPubsubMessageHandlable)) {
+      logger.warn('Unsupported instance');
+      logger.debug('Unsupported instance: ', handlable);
+      return;
+    }
+
+    this.handlableList = this.handlableList.filter(h => h !== handlable);
   }
 
 }

+ 21 - 9
src/server/service/config-pubsub/nchan.js

@@ -49,18 +49,19 @@ class NchanDelegator extends ConfigPubsubDelegator {
       }
     }
 
+    if (this.client != null && this.shouldResubscribe()) {
+      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    }
+
     // init client
     if (this.client == null) {
       this.initClient();
     }
 
-    if (this.shouldResubscribe()) {
-      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
-    }
-
     // connect
     this.isConnecting = true;
     const url = this.constructUrl(this.subscribePath).toString();
+    logger.debug(`Subscribe to ${url}`);
     this.client.connect(url.toString());
   }
 
@@ -70,9 +71,10 @@ class NchanDelegator extends ConfigPubsubDelegator {
   async publish(configPubsubMessage) {
     await super.publish(configPubsubMessage);
 
-    logger.debug('Publish message', configPubsubMessage);
-
     const url = this.constructUrl(this.publishPath).toString();
+
+    logger.debug('Publish message', configPubsubMessage, `to ${url}`);
+
     return axios.post(url, JSON.stringify(configPubsubMessage));
   }
 
@@ -81,9 +83,18 @@ class NchanDelegator extends ConfigPubsubDelegator {
    */
   addMessageHandler(handlable) {
     super.addMessageHandler(handlable);
+    this.registerMessageHandlerToConnection(handlable);
+  }
 
-    this.handlableList.push(handlable);
+  /**
+   * @inheritdoc
+   */
+  removeMessageHandler(handlable) {
+    super.removeMessageHandler(handlable);
+    this.subscribe(true);
+  }
 
+  registerMessageHandlerToConnection(handlable) {
     if (this.connection != null) {
       this.connection.on('message', (messageObj) => {
         this.handleMessage(messageObj, handlable);
@@ -122,7 +133,7 @@ class NchanDelegator extends ConfigPubsubDelegator {
       });
 
       // register all message handlers
-      this.handlableList.forEach(handler => this.addMessageHandler(handler));
+      this.handlableList.forEach(handler => this.registerMessageHandlerToConnection(handler));
     });
 
     this.client = client;
@@ -178,6 +189,7 @@ module.exports = function(crowi) {
 
   const publishPath = configManager.getConfig('crowi', 'configPubsub:nchan:publishPath');
   const subscribePath = configManager.getConfig('crowi', 'configPubsub:nchan:subscribePath');
+  const channelId = configManager.getConfig('crowi', 'configPubsub:nchan:channelId');
 
-  return new NchanDelegator(uri, publishPath, subscribePath);
+  return new NchanDelegator(uri, publishPath, subscribePath, channelId);
 };

+ 7 - 6
src/server/service/customize.js

@@ -12,12 +12,13 @@ const ConfigPubsubMessageHandlable = require('./config-pubsub/handlable');
  */
 class CustomizeService extends ConfigPubsubMessageHandlable {
 
-  constructor(configManager, appService, xssService) {
+  constructor(crowi) {
     super();
 
-    this.configManager = configManager;
-    this.appService = appService;
-    this.xssService = xssService;
+    this.configManager = crowi.configManager;
+    this.configPubsub = crowi.configPubsub;
+    this.appService = crowi.appService;
+    this.xssService = crowi.xssService;
 
     this.lastLoadedAt = null;
   }
@@ -38,7 +39,7 @@ class CustomizeService extends ConfigPubsubMessageHandlable {
    * @inheritdoc
    */
   async handleConfigPubsubMessage(configPubsubMessage) {
-    const { configManager } = this.appService;
+    const { configManager } = this;
 
     logger.info('Reset customized value by pubsub notification');
     await configManager.loadConfigs();
@@ -47,7 +48,7 @@ class CustomizeService extends ConfigPubsubMessageHandlable {
   }
 
   async publishUpdatedMessage() {
-    const { configPubsub } = this.appService;
+    const { configPubsub } = this;
 
     if (configPubsub != null) {
       const configPubsubMessage = new ConfigPubsubMessage('customizeServiceUpdated', { updatedAt: new Date() });

+ 1 - 2
src/server/views/layout/layout.html

@@ -97,8 +97,7 @@
 <div id="grw-fab-container"></div>
 {% endblock %}
 
-<!-- /#staff-credit -->
-<div id="staff-credit"></div>
+<div id="grw-hotkeys-manager"></div>
 
 {% include '../widget/system-version.html' %}
 

+ 16 - 14
src/server/views/modal/shortcuts.html

@@ -13,7 +13,7 @@
             <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.global.title') }}</strong></h3>
 
-                <table class="table table-responsive">
+                <table class="table">
                   <tr>
                     <th>{{ t('modal_shortcuts.global.Open/Close shortcut help') }}:</th>
                     <td><span class="key cmd-key"></span> + <span class="key">/</span></td>
@@ -37,6 +37,17 @@
                       <span class="key key-small">B</span>&nbsp;<span class="key key-small">A</span>
                     </td>
                   </tr>
+                  <tr>
+                    <th>{{ t('modal_shortcuts.global.MirrorMode') }}:</th>
+                    <td>
+                      <a href="{{ t('modal_shortcuts.global.konami_code_url') }}" target="_blank">{{ t('modal_shortcuts.global.Konami Code') }}</a><br>
+                      <span class="key key-small">X</span>&nbsp;<span class="key key-small">X</span>
+                      <span class="key key-small">B</span>&nbsp;<span class="key key-small">B</span>
+                      <span class="key key-small">A</span><br><span class="key key-small">Y</span>
+                      <span class="key key-small">A</span>&nbsp;<span class="key key-small">Y</span>
+                      <span class="key key-small">↓</span>&nbsp;<span class="key key-small">←</span>
+                    </td>
+                  </tr>
                 </table>
             </div><!-- /.col-lg-6 -->
 
@@ -44,7 +55,7 @@
             <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.editor.title') }}</strong></h3>
 
-              <table class="table table-responsive">
+              <table class="table">
                 <tr>
                   <th>{{ t('modal_shortcuts.editor.Indent') }}:</th>
                   <td><span class="key key-longer">Tab</span></td>
@@ -62,20 +73,10 @@
                   <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
                 </tr>
               </table>
-            </div><!-- /.col-lg-6 -->
-          </div><!-- /.row -->
-        </div><!-- /.container -->
 
-        <div class="container">
-          <div class="row">
-            <div class="col-lg-6">
-              <h3><strong></strong></h3>
-            </div><!-- /.col-lg-6 -->
-
-            <div class="col-lg-6">
               <h3><strong>{{ t('modal_shortcuts.commentform.title') }}</strong></h3>
 
-              <table class="table table-responsive">
+              <table class="table">
                 <tr>
                   <th>{{ t('modal_shortcuts.commentform.Post') }}:</th>
                   <td><span class="key cmd-key"></span> + <span class="key key-longer">{% include '../widget/icon-keyboard-return-enter.html' %}</span></td>
@@ -85,10 +86,11 @@
                   <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
                 </tr>
               </table>
-            </div><!-- /.col-lg-6 -->
 
+            </div><!-- /.col-lg-6 -->
           </div><!-- /.row -->
         </div><!-- /.container -->
+
       </div>
     </div>
 

+ 54 - 15
src/test/models/page.test.js

@@ -11,6 +11,7 @@ describe('Page', () => {
   // eslint-disable-next-line no-unused-vars
   let crowi;
   let Page;
+  let PageQueryBuilder;
   let User;
   let UserGroup;
   let UserGroupRelation;
@@ -22,6 +23,8 @@ describe('Page', () => {
     UserGroup = mongoose.model('UserGroup');
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
+    PageQueryBuilder = Page.PageQueryBuilder;
+
 
     await User.insertMany([
       { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
@@ -297,42 +300,78 @@ describe('Page', () => {
     });
   });
 
-  describe('findListWithDescendants', () => {
-    test('should return only /page/', async() => {
-      const result = await Page.findListWithDescendants('/page/', testUser0, { isRegExpEscapedFromPath: true });
+  describe('PageQueryBuilder.addConditionToListWithDescendants', () => {
+    test('can retrieve descendants of /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListWithDescendants('/page');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(1);
+      expect(result.length).toEqual(1);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page/for/extended');
     });
 
-    test('should return only /page1/', async() => {
-      const result = await Page.findListWithDescendants('/page1/', testUser0, { isRegExpEscapedFromPath: true });
+    test('can retrieve descendants of /page1', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListWithDescendants('/page1/');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(2);
+      expect(result.length).toEqual(2);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
     });
   });
 
-  describe('findListByStartWith', () => {
-    test('should return pages which starts with /page', async() => {
-      const result = await Page.findListByStartWith('/page', testUser0, {});
+  describe('PageQueryBuilder.addConditionToListOnlyDescendants', () => {
+    test('can retrieve only descendants of /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListOnlyDescendants('/page');
+
+      const result = await builder.query.exec();
+
+      // assert totalCount
+      expect(result.length).toEqual(1);
+      // assert paths
+      const pagePaths = result.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/page/for/extended');
+    });
+
+    test('can retrieve only descendants of /page1', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListOnlyDescendants('/page1');
+
+      const result = await builder.query.exec();
+
+      // assert totalCount
+      expect(result.length).toEqual(1);
+      // assert paths
+      const pagePaths = result.map((page) => { return page.path });
+      expect(pagePaths).toContainEqual('/page1/child1');
+    });
+  });
+
+  describe('PageQueryBuilder.addConditionToListByStartWith', () => {
+    test('can retrieve pages which starts with /page', async() => {
+      const builder = new PageQueryBuilder(Page.find());
+      builder.addConditionToListByStartWith('/page');
+
+      const result = await builder.query.exec();
 
       // assert totalCount
-      expect(result.totalCount).toEqual(4);
+      expect(result.length).toEqual(4);
       // assert paths
-      const pagePaths = result.pages.map((page) => { return page.path });
+      const pagePaths = result.map((page) => { return page.path });
       expect(pagePaths).toContainEqual('/page/for/extended');
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
       expect(pagePaths).toContainEqual('/page2');
     });
-
   });
 });