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

Merge pull request #2606 from weseek/feat/enhanced-link-edit-modal-for-master-merge

Feat/enhanced link edit modal for master merge
Yuki Takei 5 лет назад
Родитель
Сommit
25ade2bebb

+ 2 - 2
resource/locales/en_US/translation.json

@@ -207,8 +207,8 @@
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",
     "Page URL": "Page URL",
-    "Parmanent link": "Parmanent link",
-    "Page path and parmanent link": "Page path and parmanent link",
+    "Permanent link": "Permanent link",
+    "Page path and permanent link": "Page path and permanent link",
     "Markdown link": "Markdown link"
   },
   "search_help": {

+ 2 - 2
resource/locales/ja_JP/translation.json

@@ -210,8 +210,8 @@
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",
     "Page URL": "ページURL",
-    "Parmanent link": "パーマリンク",
-    "Page path and parmanent link": "ページ名とパーマリンク",
+    "Permanent link": "パーマリンク",
+    "Page path and permanent link": "ページ名とパーマリンク",
     "Markdown link": "マークダウン形式のリンク"
   },
   "search_help": {

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

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

+ 4 - 4
src/client/js/components/Page/CopyDropdown.jsx

@@ -153,22 +153,22 @@ class CopyDropdown extends React.Component {
 
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Parmanent Link */}
+            {/* Permanent Link */}
             { pageId && (
               <CopyToClipboard text={permalink} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Parmanent link')} contents={permalink} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
                 </DropdownItem>
               </CopyToClipboard>
             )}
 
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Page path + Parmanent Link */}
+            {/* Page path + Permanent Link */}
             { pageId && (
               <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Page path and parmanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
                 </DropdownItem>
               </CopyToClipboard>
             )}

+ 0 - 1
src/client/js/components/PageCreateModal.jsx

@@ -162,7 +162,6 @@ const PageCreateModal = (props) => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    crowi={appContainer}
                     initializedPath={pathname}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}

+ 0 - 1
src/client/js/components/PageDuplicateModal.jsx

@@ -80,7 +80,6 @@ const PageDuplicateModal = (props) => {
               {isReachable
               ? (
                 <PagePathAutoComplete
-                  crowi={appContainer}
                   initializedPath={path}
                   onSubmit={ppacSubmitHandler}
                   onInputChange={ppacInputChangeHandler}

+ 26 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -16,8 +16,10 @@ import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
+import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
@@ -54,6 +56,7 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
+const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -71,6 +74,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
@@ -98,6 +102,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
+    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
   }
@@ -462,8 +467,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
   cursorHandler(editor, event) {
     const { additionalClassSet } = this.state;
     const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
+    const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
     const isInTable = mtu.isInTable(editor);
+    const isInLink = mlu.isInLink(editor);
 
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -474,6 +481,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
       this.setState({ additionalClassSet });
     }
+
+    if (!hasLinkClass && isInLink) {
+      additionalClassSet.add(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
+
+    if (hasLinkClass && !isInLink) {
+      additionalClassSet.delete(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
   }
 
   changeHandler(editor, data, value) {
@@ -649,6 +666,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     cm.focus();
   }
 
+  showLinkEditHandler() {
+    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+  }
+
   showHandsonTableHandler() {
     this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
@@ -745,7 +766,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.createReplaceSelectionHandler('[', ']()')}
+        onClick={this.showLinkEditHandler}
       >
         <EditorIcon icon="Link" />
       </Button>,
@@ -849,6 +870,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderCheatsheetOverlay() }
 
+        <LinkEditModal
+          ref={this.linkEditModal}
+          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
+        />
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}

+ 353 - 0
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -0,0 +1,353 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { debounce } from 'throttle-debounce';
+
+import path from 'path';
+import Preview from './Preview';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
+import SearchTypeahead from '../SearchTypeahead';
+import Linker from '../../models/Linker';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+class LinkEditModal extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      isUseRelativePath: false,
+      isUsePermanentLink: false,
+      linkInputValue: '',
+      labelInputValue: '',
+      linkerType: Linker.types.markdownLink,
+      markdown: '',
+      permalink: '',
+    };
+
+    this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
+
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.handleChangeTypeahead = this.handleChangeTypeahead.bind(this);
+    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
+    this.handleChangeLinkInput = this.handleChangeLinkInput.bind(this);
+    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
+    this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
+    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
+    this.save = this.save.bind(this);
+    this.generateLink = this.generateLink.bind(this);
+    this.renderPreview = this.renderPreview.bind(this);
+    this.getRootPath = this.getRootPath.bind(this);
+
+    this.getPreviewDebounced = debounce(200, this.getPreview.bind(this));
+  }
+
+  componentDidUpdate(prevState) {
+    const { linkInputValue: prevLinkInputValue } = prevState;
+    const { linkInputValue } = this.state;
+    if (linkInputValue !== prevLinkInputValue) {
+      this.getPreviewDebounced(linkInputValue);
+    }
+  }
+
+  // defaultMarkdownLink is an instance of Linker
+  show(defaultMarkdownLink = null) {
+    // if defaultMarkdownLink is null, set default value in inputs.
+    const { label = '' } = defaultMarkdownLink;
+    let { link = '', type = Linker.types.markdownLink } = defaultMarkdownLink;
+
+    // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
+    if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
+      type = Linker.types.markdownLink;
+    }
+
+    const url = new URL(link, 'http://example.com');
+    const isUseRelativePath = url.origin === 'http://example.com' && !link.startsWith('/') && link !== '';
+    if (isUseRelativePath) {
+      const rootPath = this.getRootPath(type);
+      link = path.resolve(rootPath, link);
+    }
+
+    this.setState({
+      show: true,
+      labelInputValue: label,
+      linkInputValue: link,
+      isUsePermanentLink: false,
+      permalink: '',
+      linkerType: type,
+      isUseRelativePath,
+    });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  hide() {
+    this.setState({
+      show: false,
+    });
+  }
+
+  toggleIsUseRelativePath() {
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUseRelativePath: !this.state.isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  toggleIsUsePamanentLink() {
+    if (this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
+  }
+
+  renderPreview() {
+    return (
+      <div className="linkedit-preview">
+        <Preview
+          markdown={this.state.markdown}
+        />
+      </div>
+    );
+  }
+
+  async getPreview(path) {
+    let markdown = '';
+    let permalink = '';
+    try {
+      const res = await this.props.appContainer.apiGet('/pages.get', { path });
+      markdown = res.page.revision.body;
+      permalink = `${window.location.origin}/${res.page.id}`;
+    }
+    catch (err) {
+      markdown = `<div class="alert alert-warning" role="alert"><strong>${err.message}</strong></div>`;
+    }
+    this.setState({ markdown, permalink });
+  }
+
+  handleChangeTypeahead(selected) {
+    const page = selected[0];
+    if (page != null) {
+      this.setState({ linkInputValue: page.path });
+    }
+  }
+
+  handleChangeLabelInput(label) {
+    this.setState({ labelInputValue: label });
+  }
+
+  handleChangeLinkInput(link) {
+    let isUseRelativePath = this.state.isUseRelativePath;
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+    }
+    this.setState({ linkInputValue: link, isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  handleSelecteLinkerType(linkerType) {
+    let { isUseRelativePath, isUsePermanentLink } = this.state;
+    if (linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+      isUsePermanentLink = false;
+    }
+    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
+  }
+
+  save() {
+    const output = this.generateLink();
+
+    if (this.props.onSave != null) {
+      this.props.onSave(output);
+    }
+
+    this.hide();
+  }
+
+  generateLink() {
+    const {
+      linkInputValue,
+      labelInputValue,
+      linkerType,
+      isUseRelativePath,
+      isUsePermanentLink,
+      permalink,
+    } = this.state;
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = this.getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    return new Linker(
+      linkerType,
+      labelInputValue,
+      reshapedLink,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  getRootPath(type) {
+    const { pageContainer } = this.props;
+    const pagePath = pageContainer.state.path;
+    // rootPaths of md link and pukiwiki link are different
+    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
+  }
+
+  render() {
+    return (
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg">
+        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
+          Edit Links
+        </ModalHeader>
+
+        <ModalBody className="container">
+          <div className="row">
+            <div className="col-12 col-lg-6">
+              <form className="form-group">
+                <div className="form-gorup my-3">
+                  <label htmlFor="linkInput">Link</label>
+                  <div className="input-group">
+                    <SearchTypeahead
+                      onChange={this.handleChangeTypeahead}
+                      onInputChange={this.handleChangeLinkInput}
+                      inputName="link"
+                      placeholder="Input page path or URL"
+                      keywordOnInit={this.state.linkInputValue}
+                    />
+                  </div>
+                </div>
+              </form>
+
+              <div className="d-block d-lg-none mb-3 overflow-auto">
+                {this.renderPreview()}
+              </div>
+
+              <div className="card">
+                <div className="card-body">
+                  <form className="form-group">
+                    <div className="form-group btn-group d-flex" role="group" aria-label="type">
+                      <button
+                        type="button"
+                        name={Linker.types.markdownLink}
+                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.markdownLink && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Markdown
+                      </button>
+                      <button
+                        type="button"
+                        name={Linker.types.growiLink}
+                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.growiLink && 'active'}`}
+                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                      >
+                        Growi Original
+                      </button>
+                      {this.isApplyPukiwikiLikeLinkerPlugin && (
+                        <button
+                          type="button"
+                          name={Linker.types.pukiwikiLink}
+                          className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.pukiwikiLink && 'active'}`}
+                          onClick={e => this.handleSelecteLinkerType(e.target.name)}
+                        >
+                          Pukiwiki
+                        </button>
+                      )}
+                    </div>
+
+                    <div className="form-group">
+                      <label htmlFor="label">Label</label>
+                      <input
+                        type="text"
+                        className="form-control"
+                        id="label"
+                        value={this.state.labelInputValue}
+                        onChange={e => this.handleChangeLabelInput(e.target.value)}
+                        disabled={this.state.linkerType === Linker.types.growiLink}
+                      />
+                    </div>
+                    <div className="form-inline">
+                      <div className="custom-control custom-checkbox custom-checkbox-info">
+                        <input
+                          className="custom-control-input"
+                          id="relativePath"
+                          type="checkbox"
+                          checked={this.state.isUseRelativePath}
+                          disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
+                        />
+                        <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
+                          Use relative path
+                        </label>
+                      </div>
+                    </div>
+                    <div className="form-inline">
+                      <div className="custom-control custom-checkbox custom-checkbox-info">
+                        <input
+                          className="custom-control-input"
+                          id="permanentLink"
+                          type="checkbox"
+                          checked={this.state.isUsePermanentLink}
+                          disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
+                        />
+                        <label className="custom-control-label" htmlFor="permanentLink" onClick={this.toggleIsUsePamanentLink}>
+                          Use permanent link
+                        </label>
+                      </div>
+                    </div>
+                  </form>
+                </div>
+              </div>
+            </div>
+
+            <div className="col d-none d-lg-block pr-0 mr-3 overflow-auto">
+              {this.renderPreview()}
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.hide}>
+            Cancel
+          </button>
+          <button type="submit" className="btn btn-sm btn-primary" onClick={this.save}>
+            Done
+          </button>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+LinkEditModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  onSave: PropTypes.func,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
+
+export default LinkEditModalWrapper;

+ 48 - 0
src/client/js/components/PageEditor/MarkdownLinkUtil.js

@@ -0,0 +1,48 @@
+import Linker from '../../models/Linker';
+
+/**
+ * Utility for markdown link
+ */
+class MarkdownLinkUtil {
+
+  constructor() {
+    this.getMarkdownLink = this.getMarkdownLink.bind(this);
+    this.isInLink = this.isInLink.bind(this);
+    this.replaceFocusedMarkdownLinkWithEditor = this.replaceFocusedMarkdownLinkWithEditor.bind(this);
+  }
+
+  // return an instance of Linker from cursor position or selected text.
+  getMarkdownLink(editor) {
+    if (!this.isInLink(editor)) {
+      return Linker.fromMarkdownString(editor.getDoc().getSelection());
+    }
+    const curPos = editor.getCursor();
+    return Linker.fromLineWithIndex(editor.getDoc().getLine(curPos.line), curPos.ch);
+  }
+
+  isInLink(editor) {
+    const curPos = editor.getCursor();
+    const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(editor.getDoc().getLine(curPos.line), curPos.ch);
+    return beginningOfLink >= 0 && endOfLink >= 0;
+  }
+
+  // replace link(link is an instance of Linker)
+  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+    const curPos = editor.getCursor();
+    const linkStr = link.generateMarkdownText();
+    if (!this.isInLink(editor)) {
+      editor.getDoc().replaceSelection(linkStr);
+    }
+    else {
+      const line = editor.getDoc().getLine(curPos.line);
+      const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(line, curPos.ch);
+      editor.getDoc().replaceRange(linkStr, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
+    }
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownLinkUtil();
+Object.freeze(instance);
+export default instance;

+ 0 - 2
src/client/js/components/PagePathAutoComplete.jsx

@@ -37,7 +37,6 @@ const PagePathAutoComplete = (props) => {
 
   return (
     <SearchTypeahead
-      crowi={props.crowi}
       onSubmit={submitHandler}
       onChange={inputChangeHandler}
       onInputChange={props.onInputChange}
@@ -51,7 +50,6 @@ const PagePathAutoComplete = (props) => {
 };
 
 PagePathAutoComplete.propTypes = {
-  crowi:            PropTypes.object.isRequired,
   initializedPath:  PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 

+ 147 - 0
src/client/js/models/Linker.js

@@ -0,0 +1,147 @@
+export default class Linker {
+
+  constructor(
+      type,
+      label,
+      link,
+      isUsePermanentLink = false,
+      permalink = '',
+  ) {
+    this.type = type;
+    this.label = label;
+    this.link = link;
+    this.isUsePermanentLink = isUsePermanentLink;
+    this.permalink = permalink;
+
+    this.generateMarkdownText = this.generateMarkdownText.bind(this);
+  }
+
+  static types = {
+    markdownLink: 'mdLink',
+    growiLink: 'growiLink',
+    pukiwikiLink: 'pukiwikiLink',
+  }
+
+  static patterns = {
+    pukiwikiLinkWithLabel: /^\[\[(?<label>.+)>(?<link>.+)\]\]$/, // https://regex101.com/r/2fNmUN/2
+    pukiwikiLinkWithoutLabel: /^\[\[(?<label>.+)\]\]$/, // https://regex101.com/r/S7w5Xu/1
+    growiLink: /^\[(?<label>\/.+)\]$/, // https://regex101.com/r/DJfkYf/3
+    markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
+  }
+
+  generateMarkdownText() {
+    let reshapedLink = this.link;
+
+    if (this.isUsePermanentLink && this.permalink != null) {
+      reshapedLink = this.permalink;
+    }
+
+    if (this.label === '') {
+      this.label = reshapedLink;
+    }
+
+    if (this.type === Linker.types.pukiwikiLink) {
+      if (this.label === reshapedLink) return `[[${reshapedLink}]]`;
+      return `[[${this.label}>${reshapedLink}]]`;
+    }
+    if (this.type === Linker.types.growiLink) {
+      return `[${reshapedLink}]`;
+    }
+    if (this.type === Linker.types.markdownLink) {
+      return `[${this.label}](${reshapedLink})`;
+    }
+  }
+
+  // create an instance of Linker from string
+  static fromMarkdownString(str) {
+    // if str doesn't mean a linker, create a link whose label is str
+    let label = str;
+    let link = '';
+    let type = this.types.markdownLink;
+
+    // pukiwiki with separator ">".
+    if (str.match(this.patterns.pukiwikiLinkWithLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label, link } = str.match(this.patterns.pukiwikiLinkWithLabel).groups);
+    }
+    // pukiwiki without separator ">".
+    else if (str.match(this.patterns.pukiwikiLinkWithoutLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label } = str.match(this.patterns.pukiwikiLinkWithoutLabel).groups);
+      link = label;
+    }
+    // markdown
+    else if (str.match(this.patterns.markdownLink)) {
+      type = this.types.markdownLink;
+      ({ label, link } = str.match(this.patterns.markdownLink).groups);
+    }
+    // growi
+    else if (str.match(this.patterns.growiLink)) {
+      type = this.types.growiLink;
+      ({ label } = str.match(this.patterns.growiLink).groups);
+      link = label;
+    }
+
+    const isUsePermanentLink = false;
+    const permalink = '';
+
+    return new Linker(
+      type,
+      label,
+      link,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  // create an instance of Linker from text with index
+  static fromLineWithIndex(line, index) {
+    const { beginningOfLink, endOfLink } = this.getBeginningAndEndIndexOfLink(line, index);
+    // if index is in a link, extract it from line
+    let linkStr = '';
+    if (beginningOfLink >= 0 && endOfLink >= 0) {
+      linkStr = line.substring(beginningOfLink, endOfLink);
+    }
+    return this.fromMarkdownString(linkStr);
+  }
+
+  // return beginning and end indexies of link
+  // if index is not in a link, return { beginningOfLink: -1, endOfLink: -1 }
+  static getBeginningAndEndIndexOfLink(line, index) {
+    let beginningOfLink;
+    let endOfLink;
+
+    // pukiwiki link ('[[link]]')
+    [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[[', ']]');
+
+    // markdown link ('[label](link)')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[', ')', '](');
+    }
+
+    // growi link ('[/link]')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[/', ']');
+    }
+
+    // return { beginningOfLink: -1, endOfLink: -1 }
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = [-1, -1];
+    }
+
+    return { beginningOfLink, endOfLink };
+  }
+
+  // return begin and end indexies as array only when index is between prefix and suffix and link contains containText.
+  static getBeginningAndEndIndexWithPrefixAndSuffix(line, index, prefix, suffix, containText = '') {
+    const beginningIndex = line.lastIndexOf(prefix, index);
+    const IndexOfContainText = line.indexOf(containText, beginningIndex + prefix.length);
+    const endIndex = line.indexOf(suffix, IndexOfContainText + containText.length);
+
+    if (beginningIndex < 0 || IndexOfContainText < 0 || endIndex < 0) {
+      return [-1, -1];
+    }
+    return [beginningIndex, endIndex + suffix.length];
+  }
+
+}

+ 8 - 0
src/client/styles/scss/_linkedit-preview.scss

@@ -0,0 +1,8 @@
+.modal .modal-body .linkedit-preview {
+  height: 0;
+  padding-bottom: 50%;
+
+  .page-editor-preview-body {
+    overflow-y: unset;
+  }
+}

+ 23 - 10
src/client/styles/scss/_on-edit.scss

@@ -187,19 +187,32 @@ body.on-edit {
       }
 
       // add icon on cursor
+      .markdown-table-activated, .markdown-link-activated {
+        .CodeMirror-cursor {
+          &:after {
+            position: relative;
+            top: -1.1em;
+            left: 0.3em;
+            display: block;
+            width: 1em;
+            height: 1em;
+            content: ' ';
+  
+            background-repeat: no-repeat;
+            background-size: 1em;
+          }
+        }
+      }
+
       .markdown-table-activated .CodeMirror-cursor {
         &:after {
-          position: relative;
-          top: -1.1em;
-          left: 0.3em;
-          display: block;
-          width: 1em;
-          height: 1em;
-          content: ' ';
-
           background-image: url(/images/icons/editor/table.svg);
-          background-repeat: no-repeat;
-          background-size: 1em;
+        }
+      }
+
+      .markdown-link-activated .CodeMirror-cursor {
+        &:after {
+          background-image: url(/images/icons/editor/link.svg);
         }
       }
 

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

@@ -60,6 +60,7 @@
 @import 'staff_credit';
 @import 'waves';
 @import 'wiki';
+@import 'linkedit-preview';
 
 /*
  * for Guest User Mode