Przeglądaj źródła

Merge pull request #7654 from weseek/feat/116481-refactor-LinkEditModal

feat: 116481 refactor link edit modal
Yuki Takei 2 lat temu
rodzic
commit
bc9b860ecd

+ 21 - 13
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -9,7 +9,9 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useHandsontableModal, useDrawioModal, useTemplateModal } from '~/stores/modal';
+import {
+  useHandsontableModal, useDrawioModal, useTemplateModal, useLinkEditModal,
+} from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -22,7 +24,6 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
 import markdownListUtil from './MarkdownListUtil';
@@ -149,13 +150,13 @@ class CodeMirrorEditor extends AbstractEditor {
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
-    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
     this.showTemplateModal = this.showTemplateModal.bind(this);
+    this.showLinkEditModal = this.showLinkEditModal.bind(this);
 
   }
 
@@ -846,15 +847,21 @@ class CodeMirrorEditor extends AbstractEditor {
   //   this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   // }
 
-  showLinkEditHandler() {
-    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
-  }
-
   showTemplateModal() {
     const onSubmit = templateText => this.setValue(templateText);
     this.props.onClickTemplateBtn(onSubmit);
   }
 
+  showLinkEditModal() {
+    const onSubmit = (linkText) => {
+      return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText);
+    };
+
+    const defaultMarkdownLink = markdownLinkUtil.getMarkdownLink(this.getCodeMirror());
+
+    this.props.onClickLinkEditBtn(defaultMarkdownLink, onSubmit);
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -985,7 +992,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.showLinkEditHandler}
+        onClick={this.showLinkEditModal}
       >
         <EditorIcon icon="Link" />
       </Button>,
@@ -1125,11 +1132,6 @@ class CodeMirrorEditor extends AbstractEditor {
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
         />
          */}
-
-        <LinkEditModal
-          ref={this.linkEditModal}
-          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        />
       </div>
     );
   }
@@ -1154,6 +1156,7 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openTemplateModal } = useTemplateModal();
+  const { open: openLinkEditModal } = useLinkEditModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
     openDrawioModal(drawioMxFile, onSave);
@@ -1167,12 +1170,17 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
     openTemplateModal(onSubmit);
   }, [openTemplateModal]);
 
+  const openLinkEditModalHandler = useCallback((defaultMarkdownLink, onSubmit) => {
+    openLinkEditModal(defaultMarkdownLink, onSubmit);
+  }, [openLinkEditModal]);
+
   return (
     <CodeMirrorEditorMemoized
       ref={ref}
       onClickDrawioBtn={openDrawioModalHandler}
       onClickTableBtn={openTableModalHandler}
       onClickTemplateBtn={openTemplateModalHandler}
+      onClickLinkEditBtn={openLinkEditModalHandler}
       {...props}
     />
   );

+ 0 - 471
apps/app/src/components/PageEditor/LinkEditModal.jsx

@@ -1,471 +0,0 @@
-import React from 'react';
-
-import path from 'path';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-  Popover,
-  PopoverBody,
-} from 'reactstrap';
-import validator from 'validator';
-
-
-import Linker from '~/client/models/Linker';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { useCurrentPagePath } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
-
-import PagePreviewIcon from '../Icons/PagePreviewIcon';
-import SearchTypeahead from '../SearchTypeahead';
-
-import Preview from './Preview';
-
-
-import styles from './LinkEditPreview.module.scss';
-
-
-const logger = loggerFactory('growi:components:LinkEditModal');
-
-class LinkEditModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      isUseRelativePath: false,
-      isUsePermanentLink: false,
-      linkInputValue: '',
-      labelInputValue: '',
-      linkerType: Linker.types.markdownLink,
-      markdown: null,
-      pagePath: null,
-      previewError: '',
-      permalink: '',
-      isPreviewOpen: false,
-    };
-
-    // 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.getRootPath = this.getRootPath.bind(this);
-    this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
-    this.setMarkdown = this.setMarkdown.bind(this);
-  }
-
-  // defaultMarkdownLink is an instance of Linker
-  show(defaultMarkdownLink = null) {
-    // if defaultMarkdownLink is null, set default value in inputs.
-    const { label = '', link = '' } = defaultMarkdownLink;
-    let { 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;
-    }
-
-    this.parseLinkAndSetState(link, type);
-
-    this.setState({
-      show: true,
-      labelInputValue: label,
-      isUsePermanentLink: false,
-      permalink: '',
-      linkerType: type,
-    });
-  }
-
-  // parse link, link is ...
-  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
-  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
-  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
-  // case-4. external link (ex. 'https://growi.org')
-  // case-5. the others (ex. '')
-  parseLinkAndSetState(link, type) {
-    // create url from link, add dummy origin if link is not valid url.
-    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
-    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
-    let isFqcn = false;
-    let isUseRelativePath = false;
-    let url;
-    try {
-      const url = new URL(link, 'http://example.com');
-      isFqcn = url.origin !== 'http://example.com';
-    }
-    catch (err) {
-      logger.debug(err);
-    }
-
-    // case-1: when link is this growi's page url, return pathname only
-    let reshapedLink = url != null && url.origin === window.location.origin
-      ? decodeURIComponent(url.pathname)
-      : link;
-
-    // case-3
-    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
-      isUseRelativePath = true;
-      const rootPath = this.getRootPath(type);
-      reshapedLink = path.resolve(rootPath, reshapedLink);
-    }
-
-    this.setState({
-      linkInputValue: reshapedLink,
-      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 });
-  }
-
-  async setMarkdown() {
-    const { t } = this.props;
-    const path = this.state.linkInputValue;
-    let markdown = null;
-    let pagePath = null;
-    let permalink = '';
-    let previewError = '';
-
-    if (path.startsWith('/')) {
-      try {
-        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
-        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
-        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
-
-        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
-        const { page } = data;
-        markdown = page.revision.body;
-        pagePath = page.path;
-        permalink = page.id;
-      }
-      catch (err) {
-        previewError = err.message;
-      }
-    }
-    else {
-      previewError = t('link_edit.page_not_found_in_preview', { path });
-    }
-    this.setState({
-      markdown, pagePath, previewError, permalink,
-    });
-  }
-
-  renderLinkPreview() {
-    const linker = this.generateLink();
-    return (
-      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
-        <div className="card card-disabled w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">Markdown</p>
-          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
-        </div>
-        <div className="d-flex align-items-center justify-content-center">
-          <span className="lead mx-3">
-            <i className="d-none d-sm-block fa fa-caret-right"></i>
-            <i className="d-sm-none fa fa-caret-down"></i>
-          </span>
-        </div>
-        <div className="card w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">HTML</p>
-          <p className="text-center text-truncate">
-            <a href={linker.link}>{linker.label}</a>
-          </p>
-        </div>
-      </div>
-    );
-  }
-
-  handleChangeTypeahead(selected) {
-    const pageWithMeta = selected[0];
-    if (pageWithMeta != null) {
-      const page = pageWithMeta.data;
-      const permalink = `${window.location.origin}/${page.id}`;
-      this.setState({ linkInputValue: page.path, permalink });
-    }
-  }
-
-  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, permalink: '',
-    });
-  }
-
-  handleSelecteLinkerType(linkerType) {
-    let { isUseRelativePath, isUsePermanentLink } = this.state;
-    if (linkerType === Linker.types.growiLink) {
-      isUseRelativePath = false;
-      isUsePermanentLink = false;
-    }
-    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
-  }
-
-  save() {
-    const linker = this.generateLink();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(linker.generateMarkdownText());
-    }
-
-    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);
-    }
-
-    if (isUsePermanentLink && permalink != null) {
-      reshapedLink = permalink;
-    }
-
-    return new Linker(linkerType, labelInputValue, reshapedLink);
-  }
-
-  getRootPath(type) {
-    const { pagePath } = this.props;
-    // rootPaths of md link and pukiwiki link are different
-    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
-  }
-
-  async toggleIsPreviewOpen() {
-    // open popover
-    if (this.state.isPreviewOpen === false) {
-      this.setMarkdown();
-    }
-    this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
-  }
-
-  renderLinkAndLabelForm() {
-    const { t } = this.props;
-    const { pagePath } = this.state;
-
-    return (
-      <>
-        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
-        <form className="form-group">
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.link')}</span>
-              </div>
-              <SearchTypeahead
-                onChange={this.handleChangeTypeahead}
-                onInputChange={this.handleChangeLinkInput}
-                inputName="link"
-                placeholder={t('link_edit.placeholder_of_link_input')}
-                keywordOnInit={this.state.linkInputValue}
-                autoFocus
-              />
-              <div className="d-none d-sm-block input-group-append">
-                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
-                  <PagePreviewIcon />
-                </button>
-                <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
-                  <PopoverBody>
-                    {this.state.markdown != null && pagePath != null
-                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
-                      <Preview markdown={this.state.markdown} pagePath={pagePath} />
-                    </div>
-                    }
-                  </PopoverBody>
-                </Popover>
-              </div>
-            </div>
-          </div>
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.label')}</span>
-              </div>
-              <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}
-                placeholder={this.state.linkInputValue}
-              />
-            </div>
-          </div>
-        </form>
-      </>
-    );
-  }
-
-  renderPathFormatForm() {
-    const { t } = this.props;
-    return (
-      <div className="card well pt-3">
-        <form className="form-group mb-0">
-          <div className="form-group mb-0 row">
-            <label className="col-sm-3">{t('link_edit.path_format')}</label>
-            <div className="col-sm-9">
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="relativePath"
-                  type="checkbox"
-                  checked={this.state.isUseRelativePath}
-                  onChange={this.toggleIsUseRelativePath}
-                  disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="relativePath">
-                  {t('link_edit.use_relative_path')}
-                </label>
-              </div>
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="permanentLink"
-                  type="checkbox"
-                  checked={this.state.isUsePermanentLink}
-                  onChange={this.toggleIsUsePamanentLink}
-                  disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="permanentLink">
-                  {t('link_edit.use_permanent_link')}
-                </label>
-              </div>
-            </div>
-          </div>
-          {this.isApplyPukiwikiLikeLinkerPlugin && (
-            <div className="form-group row mb-0 mt-1">
-              <label className="col-sm-3">{t('link_edit.notation')}</label>
-              <div className="col-sm-9">
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="markdownType"
-                    value={Linker.types.markdownLink}
-                    checked={this.state.linkerType === Linker.types.markdownLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="markdownType">
-                    {t('link_edit.markdown')}
-                  </label>
-                </div>
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="pukiwikiType"
-                    value={Linker.types.pukiwikiLink}
-                    checked={this.state.linkerType === Linker.types.pukiwikiLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="pukiwikiType">
-                    {t('link_edit.pukiwiki')}
-                  </label>
-                </div>
-              </div>
-            </div>
-          )}
-        </form>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    return (
-      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg" autoFocus={false}>
-        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
-          {t('link_edit.edit_link')}
-        </ModalHeader>
-
-        <ModalBody className="container">
-          <div className="row">
-            <div className="col-12">
-              {this.renderLinkAndLabelForm()}
-              {this.renderPathFormatForm()}
-            </div>
-          </div>
-          <div className="row">
-            <div className="col-12">
-              <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
-              {this.renderLinkPreview()}
-            </div>
-          </div>
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
-            {t('Cancel')}
-          </button>
-          <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-            {t('Done')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-const LinkEditModalFc = React.memo(React.forwardRef((props, ref) => {
-  const { t } = useTranslation();
-  const { data: currentPath } = useCurrentPagePath();
-  return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
-}));
-
-LinkEditModal.propTypes = {
-  t: PropTypes.func.isRequired,
-  pagePath: PropTypes.string,
-  onSave: PropTypes.func,
-};
-
-
-export default LinkEditModalFc;

+ 372 - 0
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -0,0 +1,372 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import path from 'path';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+  Popover,
+  PopoverBody,
+} from 'reactstrap';
+import validator from 'validator';
+
+
+import Linker from '~/client/models/Linker';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useLinkEditModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { usePreviewOptions } from '~/stores/renderer';
+import loggerFactory from '~/utils/logger';
+
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
+import SearchTypeahead from '../SearchTypeahead';
+
+import Preview from './Preview';
+
+
+import styles from './LinkEditPreview.module.scss';
+
+
+const logger = loggerFactory('growi:components:LinkEditModal');
+
+export const LinkEditModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: linkEditModalStatus, close } = useLinkEditModal();
+
+  const [isUseRelativePath, setIsUseRelativePath] = useState<boolean>(false);
+  const [isUsePermanentLink, setIsUsePermanentLink] = useState<boolean>(false);
+  const [linkInputValue, setLinkInputValue] = useState<string>('');
+  const [labelInputValue, setLabelInputValue] = useState<string>('');
+  const [linkerType, setLinkerType] = useState<string>('');
+  const [markdown, setMarkdown] = useState<string>('');
+  const [pagePath, setPagePath] = useState<string>('');
+  const [previewError, setPreviewError] = useState<string>();
+  const [permalink, setPermalink] = useState<string>('');
+  const [isPreviewOpen, setIsPreviewOpen] = useState<boolean>(false);
+
+  const getRootPath = useCallback((type: string) => {
+    // rootPaths of md link and pukiwiki link are different
+    if (currentPath == null) return '';
+    return type === Linker.types.markdownLink ? path.dirname(currentPath) : currentPath;
+  }, [currentPath]);
+
+  // parse link, link is ...
+  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
+  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
+  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
+  // case-4. external link (ex. 'https://growi.org')
+  // case-5. the others (ex. '')
+  const parseLinkAndSetState = useCallback((link: string, type: string) => {
+    // create url from link, add dummy origin if link is not valid url.
+    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+    let isFqcn = false;
+    let isUseRelativePath = false;
+    let url;
+    try {
+      const url = new URL(link, 'http://example.com');
+      isFqcn = url.origin !== 'http://example.com';
+    }
+    catch (err) {
+      logger.debug(err);
+    }
+
+    // case-1: when link is this growi's page url, return pathname only
+    let reshapedLink = url != null && url.origin === window.location.origin
+      ? decodeURIComponent(url.pathname)
+      : link;
+
+    // case-3
+    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+      isUseRelativePath = true;
+      const rootPath = getRootPath(type);
+      reshapedLink = path.resolve(rootPath, reshapedLink);
+    }
+
+    setLinkInputValue(reshapedLink);
+    setIsUseRelativePath(isUseRelativePath);
+  }, [getRootPath]);
+
+  useEffect(() => {
+    if (linkEditModalStatus == null) { return }
+    const { label = '', link = '' } = linkEditModalStatus.defaultMarkdownLink ?? {};
+    const { type = Linker.types.markdownLink } = linkEditModalStatus.defaultMarkdownLink ?? {};
+
+    parseLinkAndSetState(link, type);
+    setLabelInputValue(label);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+    setLinkerType(type);
+
+  }, [linkEditModalStatus, parseLinkAndSetState]);
+
+  const toggleIsUseRelativePath = () => {
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUseRelativePath(!isUseRelativePath);
+    setIsUsePermanentLink(false);
+  };
+
+  const toggleIsUsePamanentLink = () => {
+    if (permalink === '' || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUsePermanentLink(!isUsePermanentLink);
+    setIsUseRelativePath(false);
+  };
+
+  const setMarkdownHandler = async() => {
+    const path = linkInputValue;
+    let markdown = '';
+    let pagePath = '';
+    let permalink = '';
+
+    if (path.startsWith('/')) {
+      try {
+        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
+        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
+
+        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
+        markdown = page.revision.body;
+        pagePath = page.path;
+        permalink = page.id;
+      }
+      catch (err) {
+        setPreviewError(err.message);
+      }
+    }
+    else {
+      setPreviewError(t('link_edit.page_not_found_in_preview', { path }));
+    }
+
+    setMarkdown(markdown);
+    setPagePath(pagePath);
+    setPermalink(permalink);
+  };
+
+  const generateLink = () => {
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    if (isUsePermanentLink && permalink != null) {
+      reshapedLink = permalink;
+    }
+
+    return new Linker(linkerType, labelInputValue, reshapedLink);
+  };
+
+  const renderLinkPreview = (): JSX.Element => {
+    const linker = generateLink();
+    return (
+      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
+        <div className="card card-disabled w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">Markdown</p>
+          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
+        </div>
+        <div className="d-flex align-items-center justify-content-center">
+          <span className="lead mx-3">
+            <i className="d-none d-sm-block fa fa-caret-right"></i>
+            <i className="d-sm-none fa fa-caret-down"></i>
+          </span>
+        </div>
+        <div className="card w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">HTML</p>
+          <p className="text-center text-truncate">
+            <a href={linker.link}>{linker.label}</a>
+          </p>
+        </div>
+      </div>
+    );
+  };
+
+  const handleChangeTypeahead = (selected) => {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.data;
+      const permalink = `${window.location.origin}/${page.id}`;
+      setLinkInputValue(page.path);
+      setPermalink(permalink);
+    }
+  };
+
+  const handleChangeLabelInput = (label: string) => {
+    setLabelInputValue(label);
+  };
+
+  const handleChangeLinkInput = (link) => {
+    let useRelativePath = isUseRelativePath;
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      useRelativePath = false;
+    }
+    setLinkInputValue(link);
+    setIsUseRelativePath(useRelativePath);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+  };
+
+  const save = () => {
+    const linker = generateLink();
+
+    if (linkEditModalStatus?.onSave != null) {
+      linkEditModalStatus.onSave(linker.generateMarkdownText() ?? '');
+    }
+
+    close();
+  };
+
+  const toggleIsPreviewOpen = async() => {
+    // open popover
+    if (!isPreviewOpen) {
+      setMarkdownHandler();
+    }
+    setIsPreviewOpen(!isPreviewOpen);
+  };
+
+  const renderLinkAndLabelForm = (): JSX.Element => {
+    return (
+      <>
+        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
+        <form className="form-group">
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.link')}</span>
+              </div>
+              <SearchTypeahead
+                onChange={handleChangeTypeahead}
+                onInputChange={handleChangeLinkInput}
+                placeholder={t('link_edit.placeholder_of_link_input')}
+                keywordOnInit={linkInputValue}
+                autoFocus
+              />
+              <div className="d-none d-sm-block input-group-append">
+                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
+                  <PagePreviewIcon />
+                </button>
+                <Popover trigger="focus" placement="right" isOpen={isPreviewOpen} target="preview-btn" toggle={toggleIsPreviewOpen}>
+                  <PopoverBody>
+                    {markdown != null && pagePath != null && rendererOptions != null
+                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
+                      <Preview markdown={markdown} pagePath={pagePath} rendererOptions={rendererOptions} />
+                    </div>
+                    }
+                  </PopoverBody>
+                </Popover>
+              </div>
+            </div>
+          </div>
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.label')}</span>
+              </div>
+              <input
+                type="text"
+                className="form-control"
+                id="label"
+                value={labelInputValue}
+                onChange={e => handleChangeLabelInput(e.target.value)}
+                disabled={linkerType === Linker.types.growiLink}
+                placeholder={linkInputValue}
+              />
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  };
+
+  const renderPathFormatForm = (): JSX.Element => {
+    return (
+      <div className="card well pt-3">
+        <form className="form-group mb-0">
+          <div className="form-group mb-0 row">
+            <label className="col-sm-3">{t('link_edit.path_format')}</label>
+            <div className="col-sm-9">
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="relativePath"
+                  type="checkbox"
+                  checked={isUseRelativePath}
+                  onChange={toggleIsUseRelativePath}
+                  disabled={!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="relativePath">
+                  {t('link_edit.use_relative_path')}
+                </label>
+              </div>
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="permanentLink"
+                  type="checkbox"
+                  checked={isUsePermanentLink}
+                  onChange={toggleIsUsePamanentLink}
+                  disabled={permalink === '' || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="permanentLink">
+                  {t('link_edit.use_permanent_link')}
+                </label>
+              </div>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  };
+
+  if (linkEditModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={linkEditModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        {t('link_edit.edit_link')}
+      </ModalHeader>
+
+      <ModalBody className="container">
+        <div className="row">
+          <div className="col-12">
+            {renderLinkAndLabelForm()}
+            {renderPathFormatForm()}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12">
+            <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
+            {renderLinkPreview()}
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        { previewError && <span className='text-danger'>{previewError}</span>}
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+          {t('Cancel')}
+        </button>
+        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={save}>
+          {t('Done')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+LinkEditModal.displayName = 'LinkEditModal';

+ 2 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -75,6 +75,7 @@ const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() =
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const TemplateModal = dynamic(() => import('../components/TemplateModal').then(mod => mod.TemplateModal), { ssr: false });
+const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditModal').then(mod => mod.LinkEditModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 
@@ -380,6 +381,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <HandsontableModal />
       <QuestionnaireModalManager />
       <TemplateModal />
+      <LinkEditModal />
     </>
   );
 };

+ 30 - 0
apps/app/src/stores/modal.tsx

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
 
 import { SWRResponse } from 'swr';
 
+import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
@@ -650,3 +651,32 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
     },
   });
 };
+
+/*
+ * LinkEditModal
+ */
+type LinkEditModalStatus = {
+  isOpened: boolean,
+  defaultMarkdownLink?: Linker,
+  onSave?: (linkText: string) => void
+}
+
+type LinkEditModalUtils = {
+  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
+  close(): void,
+}
+
+export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
+
+  const initialStatus: LinkEditModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
+      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};