LinkEditModal.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import type React from 'react';
  2. import { useCallback, useEffect, useState } from 'react';
  3. import { Linker } from '@growi/editor/dist/models/linker';
  4. import {
  5. useLinkEditModalActions,
  6. useLinkEditModalStatus,
  7. } from '@growi/editor/dist/states/modal/link-edit';
  8. import { useTranslation } from 'next-i18next';
  9. import path from 'path';
  10. import {
  11. Modal,
  12. ModalBody,
  13. ModalFooter,
  14. ModalHeader,
  15. Popover,
  16. PopoverBody,
  17. } from 'reactstrap';
  18. import validator from 'validator';
  19. import { apiv3Get } from '~/client/util/apiv3-client';
  20. import { useCurrentPagePath } from '~/states/page';
  21. import { usePreviewOptions } from '~/stores/renderer';
  22. import loggerFactory from '~/utils/logger';
  23. import SearchTypeahead from '../../SearchTypeahead';
  24. import Preview from '../Preview';
  25. import styles from './LinkEditPreview.module.scss';
  26. const logger = loggerFactory('growi:components:LinkEditModal');
  27. /**
  28. * LinkEditModalSubstance - Heavy processing component (rendered only when modal is open)
  29. */
  30. const LinkEditModalSubstance: React.FC = () => {
  31. const { t } = useTranslation();
  32. const currentPath = useCurrentPagePath();
  33. const { data: rendererOptions } = usePreviewOptions();
  34. const linkEditModalStatus = useLinkEditModalStatus();
  35. const { close } = useLinkEditModalActions();
  36. const [isUseRelativePath, setIsUseRelativePath] = useState<boolean>(false);
  37. const [isUsePermanentLink, setIsUsePermanentLink] = useState<boolean>(false);
  38. const [linkInputValue, setLinkInputValue] = useState<string>('');
  39. const [labelInputValue, setLabelInputValue] = useState<string>('');
  40. const [linkerType, setLinkerType] = useState<string>('');
  41. const [markdown, setMarkdown] = useState<string>('');
  42. const [pagePath, setPagePath] = useState<string>('');
  43. const [previewError, setPreviewError] = useState<string>();
  44. const [permalink, setPermalink] = useState<string>('');
  45. const [isPreviewOpen, setIsPreviewOpen] = useState<boolean>(false);
  46. const getRootPath = useCallback(
  47. (type: string) => {
  48. // rootPaths of md link and pukiwiki link are different
  49. if (currentPath == null) return '';
  50. return type === Linker.types.markdownLink
  51. ? path.dirname(currentPath)
  52. : currentPath;
  53. },
  54. [currentPath],
  55. );
  56. // parse link, link is ...
  57. // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
  58. // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
  59. // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
  60. // case-4. external link (ex. 'https://growi.org')
  61. // case-5. the others (ex. '')
  62. const parseLinkAndSetState = useCallback(
  63. (link: string, type: string) => {
  64. // create url from link, add dummy origin if link is not valid url.
  65. // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
  66. // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
  67. let isFqcn = false;
  68. let isUseRelativePath = false;
  69. let url: URL | undefined;
  70. try {
  71. url = new URL(link, 'http://example.com');
  72. isFqcn = url.origin !== 'http://example.com';
  73. } catch (err) {
  74. logger.debug(err);
  75. }
  76. // case-1: when link is this growi's page url, return pathname only
  77. let reshapedLink =
  78. url != null && url.origin === window.location.origin
  79. ? decodeURIComponent(url.pathname)
  80. : link;
  81. // case-3
  82. if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
  83. isUseRelativePath = true;
  84. const rootPath = getRootPath(type);
  85. reshapedLink = path.resolve(rootPath, reshapedLink);
  86. }
  87. setLinkInputValue(reshapedLink);
  88. setIsUseRelativePath(isUseRelativePath);
  89. },
  90. [getRootPath],
  91. );
  92. useEffect(() => {
  93. if (linkEditModalStatus == null) {
  94. return;
  95. }
  96. const { label = '', link = '' } =
  97. linkEditModalStatus.defaultMarkdownLink ?? {};
  98. const { type = Linker.types.markdownLink } =
  99. linkEditModalStatus.defaultMarkdownLink ?? {};
  100. parseLinkAndSetState(link, type);
  101. setLabelInputValue(label);
  102. setIsUsePermanentLink(false);
  103. setPermalink('');
  104. setLinkerType(type);
  105. }, [linkEditModalStatus, parseLinkAndSetState]);
  106. const toggleIsUseRelativePath = useCallback(() => {
  107. if (
  108. !linkInputValue.startsWith('/') ||
  109. linkerType === Linker.types.growiLink
  110. ) {
  111. return;
  112. }
  113. // User can't use both relativePath and permalink at the same time
  114. setIsUseRelativePath(!isUseRelativePath);
  115. setIsUsePermanentLink(false);
  116. }, [linkInputValue, linkerType, isUseRelativePath]);
  117. const toggleIsUsePamanentLink = useCallback(() => {
  118. if (permalink === '' || linkerType === Linker.types.growiLink) {
  119. return;
  120. }
  121. // User can't use both relativePath and permalink at the same time
  122. setIsUsePermanentLink(!isUsePermanentLink);
  123. setIsUseRelativePath(false);
  124. }, [permalink, linkerType, isUsePermanentLink]);
  125. const setMarkdownHandler = useCallback(async () => {
  126. const path = linkInputValue;
  127. let markdown = '';
  128. let pagePath = '';
  129. let permalink = '';
  130. if (path.startsWith('/')) {
  131. try {
  132. const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
  133. const isPermanentLink = validator.isMongoId(
  134. pathWithoutFragment.slice(1),
  135. );
  136. const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
  137. const { data } = await apiv3Get('/page', {
  138. path: pathWithoutFragment,
  139. page_id: pageId,
  140. });
  141. const { page } = data;
  142. markdown = page.revision.body;
  143. pagePath = page.path;
  144. permalink = page.id;
  145. } catch (err) {
  146. setPreviewError(err.message);
  147. }
  148. } else {
  149. setPreviewError(t('link_edit.page_not_found_in_preview', { path }));
  150. }
  151. setMarkdown(markdown);
  152. setPagePath(pagePath);
  153. setPermalink(permalink);
  154. }, [linkInputValue, t]);
  155. const generateLink = useCallback(() => {
  156. let reshapedLink = linkInputValue;
  157. if (isUseRelativePath) {
  158. const rootPath = getRootPath(linkerType);
  159. reshapedLink =
  160. rootPath === linkInputValue
  161. ? '.'
  162. : path.relative(rootPath, linkInputValue);
  163. }
  164. if (isUsePermanentLink && permalink != null) {
  165. reshapedLink = permalink;
  166. }
  167. return new Linker(linkerType, labelInputValue, reshapedLink);
  168. }, [
  169. linkInputValue,
  170. isUseRelativePath,
  171. getRootPath,
  172. linkerType,
  173. isUsePermanentLink,
  174. permalink,
  175. labelInputValue,
  176. ]);
  177. const renderLinkPreview = (): React.JSX.Element => {
  178. const linker = generateLink();
  179. return (
  180. <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
  181. <div className="card card-disabled w-100 p-1 mb-0">
  182. <p className="text-start text-muted mb-1 small">Markdown</p>
  183. <p className="text-center text-truncate text-muted">
  184. {linker.generateMarkdownText()}
  185. </p>
  186. </div>
  187. <div className="d-flex align-items-center justify-content-center">
  188. <span className="lead mx-3">
  189. <span className="d-none d-sm-block material-symbols-outlined">
  190. arrow_right
  191. </span>
  192. <span className="d-sm-none material-symbols-outlined">
  193. arrow_drop_down
  194. </span>
  195. </span>
  196. </div>
  197. <div className="card w-100 p-1 mb-0">
  198. <p className="text-start text-muted mb-1 small">HTML</p>
  199. <p className="text-center text-truncate">
  200. <a href={linker.link}>{linker.label}</a>
  201. </p>
  202. </div>
  203. </div>
  204. );
  205. };
  206. const handleChangeTypeahead = useCallback((selected) => {
  207. const pageWithMeta = selected[0];
  208. if (pageWithMeta != null) {
  209. const page = pageWithMeta.data;
  210. const permalink = `${window.location.origin}/${page.id}`;
  211. setLinkInputValue(page.path);
  212. setPermalink(permalink);
  213. }
  214. }, []);
  215. const handleChangeLabelInput = useCallback((label: string) => {
  216. setLabelInputValue(label);
  217. }, []);
  218. const handleChangeLinkInput = useCallback(
  219. (link) => {
  220. let useRelativePath = isUseRelativePath;
  221. if (
  222. !linkInputValue.startsWith('/') ||
  223. linkerType === Linker.types.growiLink
  224. ) {
  225. useRelativePath = false;
  226. }
  227. setLinkInputValue(link);
  228. setIsUseRelativePath(useRelativePath);
  229. setIsUsePermanentLink(false);
  230. setPermalink('');
  231. },
  232. [linkInputValue, isUseRelativePath, linkerType],
  233. );
  234. const save = useCallback(() => {
  235. const linker = generateLink();
  236. if (linkEditModalStatus?.onSave != null) {
  237. linkEditModalStatus.onSave(linker.generateMarkdownText() ?? '');
  238. }
  239. close();
  240. }, [generateLink, linkEditModalStatus, close]);
  241. const toggleIsPreviewOpen = useCallback(async () => {
  242. // open popover
  243. if (!isPreviewOpen) {
  244. setMarkdownHandler();
  245. }
  246. setIsPreviewOpen(!isPreviewOpen);
  247. }, [isPreviewOpen, setMarkdownHandler]);
  248. const renderLinkAndLabelForm = (): React.JSX.Element => {
  249. return (
  250. <>
  251. <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
  252. <form>
  253. <div className="form-gorup my-3">
  254. <div className="input-group flex-nowrap">
  255. <div>
  256. <span className="input-group-text">{t('link_edit.link')}</span>
  257. </div>
  258. <SearchTypeahead
  259. onChange={handleChangeTypeahead}
  260. onInputChange={handleChangeLinkInput}
  261. placeholder={t('link_edit.placeholder_of_link_input')}
  262. keywordOnInit={linkInputValue}
  263. autoFocus
  264. />
  265. <div className="d-none d-sm-block">
  266. <button
  267. type="button"
  268. id="preview-btn"
  269. className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}
  270. >
  271. <span className="material-symbols-outlined">
  272. find_in_page
  273. </span>
  274. </button>
  275. <Popover
  276. trigger="focus"
  277. placement="right"
  278. isOpen={isPreviewOpen}
  279. target="preview-btn"
  280. toggle={toggleIsPreviewOpen}
  281. >
  282. <PopoverBody>
  283. {markdown != null &&
  284. pagePath != null &&
  285. rendererOptions != null && (
  286. <div
  287. className={`linkedit-preview ${styles['linkedit-preview']}`}
  288. >
  289. <Preview
  290. markdown={markdown}
  291. pagePath={pagePath}
  292. rendererOptions={rendererOptions}
  293. />
  294. </div>
  295. )}
  296. </PopoverBody>
  297. </Popover>
  298. </div>
  299. </div>
  300. </div>
  301. <div className="form-gorup my-3">
  302. <div className="input-group flex-nowrap">
  303. <div>
  304. <span className="input-group-text">{t('link_edit.label')}</span>
  305. </div>
  306. <input
  307. type="text"
  308. className="form-control"
  309. id="label"
  310. value={labelInputValue}
  311. onChange={(e) => handleChangeLabelInput(e.target.value)}
  312. disabled={linkerType === Linker.types.growiLink}
  313. placeholder={linkInputValue}
  314. />
  315. </div>
  316. </div>
  317. </form>
  318. </>
  319. );
  320. };
  321. const renderPathFormatForm = (): React.JSX.Element => {
  322. return (
  323. <div className="card custom-card pt-3">
  324. <form className="mb-0">
  325. <div className="mb-0 row">
  326. <span className="form-label col-sm-3">
  327. {t('link_edit.path_format')}
  328. </span>
  329. <div className="col-sm-9">
  330. <div className="form-check form-check-info form-check-inline">
  331. <input
  332. className="form-check-input"
  333. id="relativePath"
  334. type="checkbox"
  335. checked={isUseRelativePath}
  336. onChange={toggleIsUseRelativePath}
  337. disabled={
  338. !linkInputValue.startsWith('/') ||
  339. linkerType === Linker.types.growiLink
  340. }
  341. />
  342. <label
  343. className="form-label form-check-label"
  344. htmlFor="relativePath"
  345. >
  346. {t('link_edit.use_relative_path')}
  347. </label>
  348. </div>
  349. <div className="form-check form-check-info form-check-inline">
  350. <input
  351. className="form-check-input"
  352. id="permanentLink"
  353. type="checkbox"
  354. checked={isUsePermanentLink}
  355. onChange={toggleIsUsePamanentLink}
  356. disabled={
  357. permalink === '' || linkerType === Linker.types.growiLink
  358. }
  359. />
  360. <label
  361. className="form-label form-check-label"
  362. htmlFor="permanentLink"
  363. >
  364. {t('link_edit.use_permanent_link')}
  365. </label>
  366. </div>
  367. </div>
  368. </div>
  369. </form>
  370. </div>
  371. );
  372. };
  373. return (
  374. <>
  375. <ModalHeader tag="h4" toggle={close}>
  376. {t('link_edit.edit_link')}
  377. </ModalHeader>
  378. <ModalBody className="container">
  379. <div className="row">
  380. <div className="col-12">
  381. {renderLinkAndLabelForm()}
  382. {renderPathFormatForm()}
  383. </div>
  384. </div>
  385. <div className="row">
  386. <div className="col-12">
  387. <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
  388. {renderLinkPreview()}
  389. </div>
  390. </div>
  391. </ModalBody>
  392. <ModalFooter>
  393. {previewError && <span className="text-danger">{previewError}</span>}
  394. <button
  395. type="button"
  396. className="btn btn-sm btn-outline-secondary mx-1"
  397. onClick={close}
  398. >
  399. {t('Cancel')}
  400. </button>
  401. <button
  402. type="submit"
  403. className="btn btn-sm btn-primary mx-1"
  404. onClick={save}
  405. >
  406. {t('Done')}
  407. </button>
  408. </ModalFooter>
  409. </>
  410. );
  411. };
  412. /**
  413. * LinkEditModal - Container component (lightweight, always rendered)
  414. */
  415. export const LinkEditModal = (): React.JSX.Element => {
  416. const linkEditModalStatus = useLinkEditModalStatus();
  417. const { close } = useLinkEditModalActions();
  418. const isOpened = linkEditModalStatus?.isOpened ?? false;
  419. return (
  420. <Modal
  421. className="link-edit-modal"
  422. isOpen={isOpened}
  423. toggle={close}
  424. size="lg"
  425. autoFocus={false}
  426. >
  427. {isOpened && <LinkEditModalSubstance />}
  428. </Modal>
  429. );
  430. };
  431. LinkEditModal.displayName = 'LinkEditModal';