import React, { useEffect, useState, useCallback, } from 'react'; import path from 'path'; import { Linker } from '@growi/editor/dist/models/linker'; import { useLinkEditModalStatus, useLinkEditModalActions } from '@growi/editor/dist/states/modal/link-edit'; import { useTranslation } from 'next-i18next'; import { Modal, ModalHeader, ModalBody, ModalFooter, Popover, PopoverBody, } from 'reactstrap'; import validator from 'validator'; import { apiv3Get } from '~/client/util/apiv3-client'; import { useCurrentPagePath } from '~/states/page'; import { usePreviewOptions } from '~/stores/renderer'; import loggerFactory from '~/utils/logger'; import SearchTypeahead from '../../SearchTypeahead'; import Preview from '../Preview'; import styles from './LinkEditPreview.module.scss'; const logger = loggerFactory('growi:components:LinkEditModal'); /** * LinkEditModalSubstance - Heavy processing component (rendered only when modal is open) */ const LinkEditModalSubstance: React.FC = () => { const { t } = useTranslation(); const currentPath = useCurrentPagePath(); const { data: rendererOptions } = usePreviewOptions(); const linkEditModalStatus = useLinkEditModalStatus(); const { close } = useLinkEditModalActions(); const [isUseRelativePath, setIsUseRelativePath] = useState(false); const [isUsePermanentLink, setIsUsePermanentLink] = useState(false); const [linkInputValue, setLinkInputValue] = useState(''); const [labelInputValue, setLabelInputValue] = useState(''); const [linkerType, setLinkerType] = useState(''); const [markdown, setMarkdown] = useState(''); const [pagePath, setPagePath] = useState(''); const [previewError, setPreviewError] = useState(); const [permalink, setPermalink] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(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 = useCallback(() => { if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) { return; } // User can't use both relativePath and permalink at the same time setIsUseRelativePath(!isUseRelativePath); setIsUsePermanentLink(false); }, [linkInputValue, linkerType, isUseRelativePath]); const toggleIsUsePamanentLink = useCallback(() => { if (permalink === '' || linkerType === Linker.types.growiLink) { return; } // User can't use both relativePath and permalink at the same time setIsUsePermanentLink(!isUsePermanentLink); setIsUseRelativePath(false); }, [permalink, linkerType, isUsePermanentLink]); const setMarkdownHandler = useCallback(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); }, [linkInputValue, t]); const generateLink = useCallback(() => { 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); }, [linkInputValue, isUseRelativePath, getRootPath, linkerType, isUsePermanentLink, permalink, labelInputValue]); const renderLinkPreview = (): React.JSX.Element => { const linker = generateLink(); return (

Markdown

{linker.generateMarkdownText()}

arrow_right arrow_drop_down
); }; const handleChangeTypeahead = useCallback((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 = useCallback((label: string) => { setLabelInputValue(label); }, []); const handleChangeLinkInput = useCallback((link) => { let useRelativePath = isUseRelativePath; if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) { useRelativePath = false; } setLinkInputValue(link); setIsUseRelativePath(useRelativePath); setIsUsePermanentLink(false); setPermalink(''); }, [linkInputValue, isUseRelativePath, linkerType]); const save = useCallback(() => { const linker = generateLink(); if (linkEditModalStatus?.onSave != null) { linkEditModalStatus.onSave(linker.generateMarkdownText() ?? ''); } close(); }, [generateLink, linkEditModalStatus, close]); const toggleIsPreviewOpen = useCallback(async() => { // open popover if (!isPreviewOpen) { setMarkdownHandler(); } setIsPreviewOpen(!isPreviewOpen); }, [isPreviewOpen, setMarkdownHandler]); const renderLinkAndLabelForm = (): React.JSX.Element => { return ( <>

{t('link_edit.set_link_and_label')}

{t('link_edit.link')}
{markdown != null && pagePath != null && rendererOptions != null && (
) }
{t('link_edit.label')}
handleChangeLabelInput(e.target.value)} disabled={linkerType === Linker.types.growiLink} placeholder={linkInputValue} />
); }; const renderPathFormatForm = (): React.JSX.Element => { return (
); }; return ( <> {t('link_edit.edit_link')}
{renderLinkAndLabelForm()} {renderPathFormatForm()}

{t('link_edit.preview')}

{renderLinkPreview()}
{ previewError && {previewError}} ); }; /** * LinkEditModal - Container component (lightweight, always rendered) */ export const LinkEditModal = (): React.JSX.Element => { const linkEditModalStatus = useLinkEditModalStatus(); const { close } = useLinkEditModalActions(); const isOpened = linkEditModalStatus?.isOpened ?? false; return ( {isOpened && } ); }; LinkEditModal.displayName = 'LinkEditModal';