import { MarkViewContent, MarkViewProps } from "@tiptap/react"; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { IconFileDescription, IconCopy, IconExternalLink, IconLinkOff, IconPencil, IconWorld, } from "@tabler/icons-react"; import { useState, useCallback, useRef, useEffect } from "react"; import { notifications } from "@mantine/notifications"; import { Divider, Group, Popover, Text, TextInput, ActionIcon, Tooltip, UnstyledButton, } from "@mantine/core"; import classes from "./link.module.css"; import { useTranslation } from "react-i18next"; import { INTERNAL_LINK_REGEX } from "@/lib/constants"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { extractPageSlugId } from "@/lib"; import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext"; import { normalizeUrl } from "@/lib/utils"; const parseInternalLink = ( href: string, internalAttr?: boolean, ): { isInternal: boolean; slugId: string | null; label: string } => { if (!href) return { isInternal: !!internalAttr, slugId: null, label: "" }; const match = INTERNAL_LINK_REGEX.exec(href); if (!match) { if (internalAttr) return { isInternal: true, slugId: null, label: href }; return { isInternal: false, slugId: null, label: href }; } const isExternal = match[2] && match[2] !== window.location.host; const slug = match[5]; const slugId = extractPageSlugId(slug); const namePart = slug.split("-").slice(0, -1).join("-"); return { isInternal: !isExternal, slugId, label: namePart || slug, }; }; export default function LinkView(props: MarkViewProps) { const { mark, editor } = props; const href = mark.attrs.href as string; const navigate = useNavigate(); const location = useLocation(); const { shareId, pageSlug } = useParams(); const { t } = useTranslation(); const isShareRoute = location.pathname.startsWith("/share"); const [popoverState, setPopoverState] = useState< "closed" | "preview" | "edit" >("closed"); const [linkTitle, setLinkTitle] = useState(""); const [linkUrl, setLinkUrl] = useState(""); const [showSearch, setShowSearch] = useState(false); const lastOpenState = useRef<"preview" | "edit">("preview"); const wrapperRef = useRef(null); const dropdownRef = useRef(null); const isEditable = editor.isEditable; const { isInternal, slugId, label: linkLabel, } = parseInternalLink(href, mark.attrs.internal); const isPopoverVisible = popoverState !== "closed"; const activeView = isPopoverVisible ? popoverState : lastOpenState.current; const { data: linkedPage } = usePageQuery({ pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null, }); const { data: sharedPageData } = useSharePageQuery({ pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null, }); const pageTitle = isShareRoute ? sharedPageData?.page?.title : linkedPage?.title; const pendingTitleRef = useRef(null); const titleInputRef = useRef(null); const getLinkPos = useCallback((): number | null => { if (!wrapperRef.current) return null; try { return editor.view.posAtDOM(wrapperRef.current, 0); } catch { return null; } }, [editor]); const handleUpdateLinkTitle = useCallback( (newTitle: string) => { if (!newTitle) return; const pos = getLinkPos(); if (pos === null) return; const { state } = editor; const resolved = state.doc.resolve(pos); const node = resolved.nodeAfter; if (!node?.isText) return; const linkMark = node.marks.find( (m) => m.type.name === "link" && m.attrs.href === href, ); if (!linkMark || node.text === newTitle) return; const from = pos; const to = pos + node.nodeSize; const { tr } = state; tr.insertText(newTitle, from, to); tr.addMark(from, from + newTitle.length, linkMark); editor.view.dispatch(tr); }, [editor, href, getLinkPos], ); const handleEditLink = useCallback( (url: string, internal?: boolean) => { const normalizedUrl = internal ? url : normalizeUrl(url); const pos = getLinkPos(); if (pos === null) { setPopoverState("closed"); return; } const { state } = editor; const resolved = state.doc.resolve(pos); const node = resolved.nodeAfter; if (!node?.isText) { setPopoverState("closed"); return; } const linkMark = node.marks.find( (m) => m.type.name === "link" && m.attrs.href === href, ); if (linkMark) { const from = pos; const to = pos + node.nodeSize; const { tr } = state; tr.removeMark(from, to, linkMark.type); tr.addMark( from, to, linkMark.type.create({ href: normalizedUrl, internal: !!internal }), ); editor.view.dispatch(tr); } setPopoverState("closed"); }, [editor, href, getLinkPos], ); useEffect(() => { if (popoverState === "edit") { const text = wrapperRef.current?.querySelector("a")?.textContent || ""; setLinkTitle(text); setLinkUrl(href); pendingTitleRef.current = null; requestAnimationFrame(() => titleInputRef.current?.focus()); } if (popoverState === "closed") { if (pendingTitleRef.current !== null) { handleUpdateLinkTitle(pendingTitleRef.current); pendingTitleRef.current = null; } setShowSearch(false); } }, [popoverState, href, isInternal, handleUpdateLinkTitle]); useEffect(() => { if (popoverState !== "closed") { lastOpenState.current = popoverState; } }, [popoverState]); useEffect(() => { if (!isPopoverVisible) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if ( wrapperRef.current?.contains(target) || dropdownRef.current?.contains(target) ) { return; } setPopoverState("closed"); }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { setPopoverState("closed"); } }; document.addEventListener("mousedown", handleClickOutside, true); document.addEventListener("keydown", handleEscape, true); return () => { document.removeEventListener("mousedown", handleClickOutside, true); document.removeEventListener("keydown", handleEscape, true); }; }, [isPopoverVisible]); const handleNavigate = useCallback(() => { if (!href) return; if (isInternal) { let targetPath = href; let anchor = ""; try { const url = new URL(href); targetPath = url.pathname; anchor = url.hash.slice(1); } catch { if (href.includes("#")) { [targetPath, anchor] = href.split("#"); } } if (anchor) { const currentPageSlugId = extractPageSlugId(pageSlug); if (!slugId || currentPageSlugId === slugId) { const element = document.querySelector(`[id="${anchor}"]`) || document.querySelector(`[data-id="${anchor}"]`); if (element) { element.scrollIntoView({ behavior: "smooth", block: "start" }); navigate(`${location.pathname}#${anchor}`, { replace: true }); return; } } } if (isShareRoute && slugId) { const sharedUrl = buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle: pageTitle, anchorId: anchor || undefined, }); navigate(sharedUrl); } else { navigate(anchor ? `${targetPath}#${anchor}` : targetPath); } } else { window.open( sanitizeUrl(normalizeUrl(href)), "_blank", "noopener,noreferrer", ); } }, [ href, navigate, location.pathname, isInternal, isShareRoute, slugId, shareId, pageTitle, pageSlug, ]); const handleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (isEditable) { setPopoverState("preview"); } else { handleNavigate(); } }, [handleNavigate, isEditable], ); const handleCopy = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const fullUrl = sanitizeUrl( isInternal ? `${window.location.origin}${href}` : href, ); copyToClipboard(fullUrl); notifications.show({ message: t("Link copied"), }); setPopoverState("closed"); }, [href, isInternal, t], ); const handleRemoveLink = useCallback(() => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setPopoverState("closed"); }, [editor]); const displayHref = sanitizeUrl( isInternal ? isShareRoute && slugId ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }) : href : normalizeUrl(href), ); const linkTitleInput = ( <> {t("Link title")} { const val = e.currentTarget.value; setLinkTitle(val); pendingTitleRef.current = val; const anchor = wrapperRef.current?.querySelector("a"); if (anchor && val) { const walker = document.createTreeWalker( anchor, NodeFilter.SHOW_TEXT, ); const textNode = walker.nextNode(); if (textNode) { const view = editor.view as any; view.domObserver.stop(); textNode.nodeValue = val; view.domObserver.start(); } } }} onBlur={() => { if (pendingTitleRef.current !== null) { handleUpdateLinkTitle(pendingTitleRef.current); pendingTitleRef.current = null; } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleUpdateLinkTitle(linkTitle); pendingTitleRef.current = null; setPopoverState("closed"); } }} size="sm" /> ); return ( e.preventDefault()} target={isInternal ? undefined : "_blank"} rel={isInternal ? undefined : "noopener noreferrer"} > e.stopPropagation()} > {activeView === "edit" ? ( <> {t("Page or URL")} {isInternal ? ( !showSearch ? ( <> setShowSearch(true)} > {pageTitle || linkTitle} {linkTitleInput} {t("Remove link")} ) : ( ) ) : ( <> } classNames={{ input: classes.linkInput }} value={linkUrl} onChange={(e) => setLinkUrl(e.currentTarget.value)} onBlur={() => { if (linkUrl && linkUrl !== href) { handleEditLink(linkUrl, false); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (linkUrl && linkUrl !== href) { handleEditLink(linkUrl, false); } } }} size="sm" /> {linkTitleInput} {t("Remove link")} )} ) : ( { e.preventDefault(); handleNavigate(); }} > {isInternal ? ( ) : ( )} {isInternal ? pageTitle || linkLabel : href} { e.preventDefault(); e.stopPropagation(); setShowSearch(false); setPopoverState("edit"); }} > { e.preventDefault(); e.stopPropagation(); handleCopy(e); }} > { e.preventDefault(); e.stopPropagation(); handleRemoveLink(); }} > )} ); }