From 9d18c36eb35d80786b42a1588d0475fc204a2082 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:45:47 +0000 Subject: [PATCH] feat: refactor links --- .../public/locales/en-US/translation.json | 5 + .../src/components/ui/auto-tooltip-text.tsx | 1 + .../components/bubble-menu/link-selector.tsx | 10 +- .../components/link/link-editor-panel.tsx | 204 ++++- .../editor/components/link/link-menu.tsx | 103 --- .../editor/components/link/link-preview.tsx | 60 -- .../editor/components/link/link-view.tsx | 798 +++++++++++------- .../editor/components/link/link.module.css | 123 ++- .../features/editor/components/link/types.ts | 2 +- .../components/link/use-link-editor-state.tsx | 8 +- .../src/features/editor/styles/core.css | 12 +- .../features/search/queries/search-query.ts | 3 +- apps/server/src/ee | 2 +- .../src/integrations/export/export.service.ts | 1 + apps/server/src/integrations/export/utils.ts | 5 + .../services/file-import-task.service.ts | 7 + .../import/utils/import-formatter.ts | 29 +- packages/editor-ext/src/lib/link.ts | 13 + 18 files changed, 816 insertions(+), 570 deletions(-) delete mode 100644 apps/client/src/features/editor/components/link/link-menu.tsx delete mode 100644 apps/client/src/features/editor/components/link/link-preview.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index da0f0b81..2d9675b2 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -289,6 +289,11 @@ "Save & Exit": "Save & Exit", "Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram", "Paste link": "Paste link", + "Paste link or search pages": "Paste link or search pages", + "Link to web page": "Link to web page", + "Recents": "Recents", + "Page or URL": "Page or URL", + "Link title": "Link title", "Edit link": "Edit link", "Remove link": "Remove link", "Add link": "Add link", diff --git a/apps/client/src/components/ui/auto-tooltip-text.tsx b/apps/client/src/components/ui/auto-tooltip-text.tsx index 419ec3d9..962a701a 100644 --- a/apps/client/src/components/ui/auto-tooltip-text.tsx +++ b/apps/client/src/components/ui/auto-tooltip-text.tsx @@ -34,6 +34,7 @@ export function AutoTooltipText({ disabled={!isTruncated || !label} multiline withArrow + withinPortal={false} {...tooltipProps} > = ({ }) => { const { t } = useTranslation(); const onLink = useCallback( - (url: string) => { + (url: string, internal?: boolean) => { setIsOpen(false); editor .chain() .focus() - .setLink({ href: url }) + .setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any) .command(({ tr }) => { tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); return true; @@ -36,11 +37,12 @@ export const LinkSelector: FC = ({ return ( @@ -58,7 +60,7 @@ export const LinkSelector: FC = ({ - + diff --git a/apps/client/src/features/editor/components/link/link-editor-panel.tsx b/apps/client/src/features/editor/components/link/link-editor-panel.tsx index e7e3b72b..d2cce213 100644 --- a/apps/client/src/features/editor/components/link/link-editor-panel.tsx +++ b/apps/client/src/features/editor/components/link/link-editor-panel.tsx @@ -1,9 +1,23 @@ -import React from "react"; -import { Button, Group, TextInput } from "@mantine/core"; -import { IconLink } from "@tabler/icons-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Group, + ScrollArea, + Text, + TextInput, + UnstyledButton, +} from "@mantine/core"; +import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react"; import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx"; import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts"; import { useTranslation } from "react-i18next"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useParams } from "react-router-dom"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; +import clsx from "clsx"; +import classes from "./link.module.css"; export const LinkEditorPanel = ({ onSetLink, @@ -11,33 +25,175 @@ export const LinkEditorPanel = ({ onUnsetLink, }: LinkEditorPanelProps) => { const { t } = useTranslation(); - const state = useLinkEditorState({ - onSetLink, - initialUrl, + const { spaceSlug } = useParams(); + const { data: space } = useSpaceQuery(spaceSlug); + const state = useLinkEditorState({ onSetLink, initialUrl }); + const [selectedIndex, setSelectedIndex] = useState(0); + const viewportRef = useRef(null); + + const { data: suggestion } = useSearchSuggestionsQuery({ + query: state.isSearchQuery ? state.url : "", + includeUsers: false, + includePages: true, + spaceId: space?.id, + limit: state.isSearchQuery ? 10 : 5, + preload: true, }); + const pages: Partial[] = suggestion?.pages ?? []; + + useEffect(() => { + setSelectedIndex(0); + }, [pages.length]); + + const selectPage = useCallback( + (page: Partial) => { + const url = buildPageUrl( + page.space?.slug || spaceSlug, + page.slugId, + page.title, + ); + onSetLink(url, true); + }, + [onSetLink, spaceSlug], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const hasUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery); + const total = (hasUrlItem ? 1 : 0) + (state.isValidUrl ? 0 : pages.length); + if (total === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, total - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (hasUrlItem && selectedIndex === 0) { + onSetLink(state.url, false); + } else { + const pageIndex = hasUrlItem ? selectedIndex - 1 : selectedIndex; + if (pageIndex >= 0 && pageIndex < pages.length) { + selectPage(pages[pageIndex]); + } + } + } + }, + [pages, selectedIndex, selectPage, state.isValidUrl, state.isSearchQuery, state.url, onSetLink], + ); + + useEffect(() => { + viewportRef.current + ?.querySelector(`[data-item-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const showPages = pages.length > 0 && !state.isValidUrl; + const showUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery); + const showDropdown = showPages || showUrlItem; + return (
- - } - variant="filled" - placeholder={t("Paste link")} - value={state.url} - onChange={state.onChange} - style={{ flex: 1 }} - /> - - {onUnsetLink && ( - - )} - + } + classNames={{ input: classes.linkInput }} + placeholder={t("Paste link or search pages")} + value={state.url} + onChange={state.onChange} + onKeyDown={handleKeyDown} + autoFocus + /> + + {showDropdown && ( + <> + {!state.isSearchQuery && !state.isValidUrl && ( + + {t("Recents")} + + )} + + 0 ? 8 : 0} + styles={{ content: { minWidth: 0 } }} + > + {showUrlItem && ( + onSetLink(state.url, false)} + className={clsx(classes.searchItem, { + [classes.selectedSearchItem]: selectedIndex === 0, + })} + > + + + + + +
+ + {state.url} + + + {t("Link to web page")} + +
+
+
+ )} + + {!state.isValidUrl && pages.map((page, index) => { + const itemIndex = showUrlItem ? index + 1 : index; + return ( + selectPage(page)} + className={clsx(classes.searchItem, { + [classes.selectedSearchItem]: itemIndex === selectedIndex, + })} + > + + + {page.icon || } + + +
+ + {page.title || t("Untitled")} + + {page.space?.name && ( + + {page.space.name} + + )} +
+
+
+ ); + })} +
+ + )} + + {onUnsetLink && ( + + + {t("Remove link")} + + + )}
); }; diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx deleted file mode 100644 index 81e77f57..00000000 --- a/apps/client/src/features/editor/components/link/link-menu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; -import React, { useCallback, useState } from "react"; -import { TextSelection } from "@tiptap/pm/state"; -import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; -import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; -import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; -import { Card } from "@mantine/core"; -import { useEditorState } from "@tiptap/react"; - -export function LinkMenu({ editor, appendTo }: EditorMenuProps) { - const [showEdit, setShowEdit] = useState(false); - - const shouldShow = useCallback(() => { - return editor.isActive("link"); - }, [editor]); - - const editorState = useEditorState({ - editor, - selector: (ctx) => { - if (!ctx.editor) { - return null; - } - const link = ctx.editor.getAttributes("link"); - return { - href: link.href, - }; - }, - }); - - const handleEdit = useCallback(() => { - setShowEdit(true); - }, []); - - const onSetLink = useCallback( - (url: string) => { - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href: url }) - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); - return true; - }) - .run(); - setShowEdit(false); - }, - [editor], - ); - - const onUnsetLink = useCallback(() => { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - setShowEdit(false); - return null; - }, [editor]); - - const onShowEdit = useCallback(() => { - setShowEdit(true); - }, []); - - const onHideEdit = useCallback(() => { - setShowEdit(false); - }, []); - - return ( - { - setShowEdit(false); - }, - placement: "bottom", - offset: 5, - // zIndex: 101, - }} - shouldShow={shouldShow} - > - {showEdit ? ( - - - - ) : ( - - )} - - ); -} - -export default LinkMenu; diff --git a/apps/client/src/features/editor/components/link/link-preview.tsx b/apps/client/src/features/editor/components/link/link-preview.tsx deleted file mode 100644 index 8b0de952..00000000 --- a/apps/client/src/features/editor/components/link/link-preview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - Tooltip, - ActionIcon, - Card, - Divider, - Anchor, - Flex, -} from "@mantine/core"; -import { IconLinkOff, IconPencil } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import classes from "./link.module.css"; - -export type LinkPreviewPanelProps = { - url: string; - onEdit: () => void; - onClear: () => void; -}; - -export const LinkPreviewPanel = ({ - onClear, - onEdit, - url, -}: LinkPreviewPanelProps) => { - const { t } = useTranslation(); - - return ( - <> - - - - - {url} - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx index ecab9411..90b89e82 100644 --- a/apps/client/src/features/editor/components/link/link-view.tsx +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -1,61 +1,63 @@ import { MarkViewContent, MarkViewProps } from "@tiptap/react"; -import { useNavigate, useLocation } from "react-router-dom"; +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 { useLongPress } from "@/features/editor/hooks/use-long-press"; import { notifications } from "@mantine/notifications"; import { - Card, + Divider, Group, - Button, - TextInput, + Popover, Text, + TextInput, ActionIcon, - Stack, - CloseButton, Tooltip, + UnstyledButton, } from "@mantine/core"; import classes from "./link.module.css"; import { useTranslation } from "react-i18next"; -import { createPortal } from "react-dom"; 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"; -const isTouchDevice = () => { - if (typeof window === "undefined") return false; - return "ontouchstart" in window || navigator.maxTouchPoints > 0; +export const normalizeUrl = (url: string): string => { + if (!url) return url; + if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url; + return `https://${url}`; }; -const isInternalLink = (href: string): boolean => { - if (!href) return false; - const match = INTERNAL_LINK_REGEX.exec(href); - if (!match) return false; - - return !(match[2] && match[2] !== window.location.host); -}; - -const extractLinkLabel = (href: string): string => { - if (!href) return ""; +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) { - const slug = match[5]; - // Extract page name from slug (remove the ID suffix) - const namePart = slug.split("-").slice(0, -1).join("-"); - return namePart || slug; + if (!match) { + if (internalAttr) return { isInternal: true, slugId: null, label: href }; + return { isInternal: false, slugId: null, label: href }; } - // For external links, show domain - try { - const url = new URL(href); - return url.hostname.replace("www.", ""); - } catch { - return href.slice(0, 30); - } + 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) { @@ -63,361 +65,519 @@ export default function LinkView(props: MarkViewProps) { 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 [isHovered, setIsHovered] = useState(false); - const [showEditPanel, setShowEditPanel] = useState(false); - const [editUrl, setEditUrl] = useState(href); - const [editTitle, setEditTitle] = useState(""); + 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 hoverTimeoutRef = useRef | null>(null); - const isTouch = isTouchDevice(); + const dropdownRef = useRef(null); const isEditable = editor.isEditable; - const isInternal = isInternalLink(href); + 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 getLinkText = useCallback(() => { - const { state } = editor; - let text = ""; - state.doc.descendants((node) => { const linkMark = node.marks.find( (m) => m.type.name === "link" && m.attrs.href === href, ); - if (linkMark && node.isText) { - text = node.text || ""; - return false; + 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; } - }); - return text; - }, [editor, href]); + + 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 (showEditPanel) { - setEditUrl(href); - setEditTitle(getLinkText()); + if (popoverState === "edit") { + const text = wrapperRef.current?.querySelector("a")?.textContent || ""; + setLinkTitle(text); + setLinkUrl(href); + pendingTitleRef.current = null; + requestAnimationFrame(() => titleInputRef.current?.focus()); } - }, [showEditPanel, href, getLinkText]); - - const handleMouseEnter = useCallback(() => { - if (showEditPanel) return; - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; + if (popoverState === "closed") { + if (pendingTitleRef.current !== null) { + handleUpdateLinkTitle(pendingTitleRef.current); + pendingTitleRef.current = null; + } + setShowSearch(false); } - setIsHovered(true); - }, [showEditPanel]); + }, [popoverState, href, isInternal, handleUpdateLinkTitle]); - const handleMouseLeave = useCallback(() => { - if (showEditPanel) return; - hoverTimeoutRef.current = setTimeout(() => { - setIsHovered(false); - }, 200); - }, [showEditPanel]); + 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) { - // Get pathname for navigation (handle both relative and absolute URLs) let targetPath = href; let anchor = ""; try { const url = new URL(href); targetPath = url.pathname; - anchor = url.hash.slice(1); // Remove the # prefix + anchor = url.hash.slice(1); } catch { - // Relative URL if (href.includes("#")) { [targetPath, anchor] = href.split("#"); } } - // Handle anchor links on same page if (anchor) { - const currentPath = location.pathname; - if (!targetPath || targetPath === currentPath) { - const element = document.getElementById(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(`${currentPath}#${anchor}`, { replace: true }); + navigate(`${location.pathname}#${anchor}`, { replace: true }); return; } } } - navigate(anchor ? `${targetPath}#${anchor}` : targetPath); + 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(href, "_blank", "noopener,noreferrer"); + window.open( + sanitizeUrl(normalizeUrl(href)), + "_blank", + "noopener,noreferrer", + ); } - }, [href, navigate, location.pathname, isInternal]); + }, [ + href, + navigate, + location.pathname, + isInternal, + isShareRoute, + slugId, + shareId, + pageTitle, + pageSlug, + ]); const handleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (!showEditPanel) { + if (isEditable) { + setPopoverState("preview"); + } else { handleNavigate(); } }, - [handleNavigate, showEditPanel], + [handleNavigate, isEditable], ); - const handleLongPress = useCallback(() => { - if (isEditable) { - setShowEditPanel(true); - setIsHovered(false); - } - }, [isEditable]); - - const handleTapNavigate = useCallback( - (e: React.TouchEvent | React.MouseEvent) => { - e.preventDefault(); - if (!showEditPanel) { - handleNavigate(); - } - }, - [handleNavigate, showEditPanel], - ); - - const longPressHandlers = useLongPress({ - threshold: 500, - onLongPress: handleLongPress, - onClick: handleTapNavigate, - }); - - const handleOpenEdit = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setShowEditPanel(true); - setIsHovered(false); - }, []); - - const handleCloseEdit = useCallback(() => { - setShowEditPanel(false); - }, []); - const handleCopy = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const fullUrl = isInternal ? `${window.location.origin}${href}` : href; - navigator.clipboard.writeText(fullUrl); + const fullUrl = sanitizeUrl( + isInternal ? `${window.location.origin}${href}` : href, + ); + copyToClipboard(fullUrl); notifications.show({ - message: t("Link copied to clipboard"), - color: "green", - autoClose: 2000, + message: t("Link copied"), }); + setPopoverState("closed"); }, [href, isInternal, t], ); - const handleSave = useCallback(() => { - const { state } = editor; - const { tr } = state; - - let updated = false; - state.doc.descendants((node, pos) => { - if (updated) return false; - - const linkMark = node.marks.find( - (m) => m.type.name === "link" && m.attrs.href === href, - ); - if (linkMark && node.isText) { - const from = pos; - const to = pos + node.nodeSize; - - if (editUrl !== href) { - tr.removeMark(from, to, linkMark.type); - tr.addMark(from, to, linkMark.type.create({ href: editUrl })); - } - - const currentText = node.text || ""; - if (editTitle && editTitle !== currentText) { - tr.replaceWith( - from, - to, - state.schema.text(editTitle, [ - linkMark.type.create({ href: editUrl || href }), - ]), - ); - } - - updated = true; - return false; - } - }); - - if (updated) { - editor.view.dispatch(tr); - } - - setShowEditPanel(false); - }, [editor, href, editUrl, editTitle]); - const handleRemoveLink = useCallback(() => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); - setShowEditPanel(false); + setPopoverState("closed"); }, [editor]); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Stop all keyboard events from bubbling to TipTap editor - e.stopPropagation(); - - if (e.key === "Enter") { - e.preventDefault(); - handleSave(); - } else if (e.key === "Escape") { - e.preventDefault(); - handleCloseEdit(); - } - }, - [handleSave, handleCloseEdit], + const displayHref = sanitizeUrl( + isInternal + ? isShareRoute && slugId + ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }) + : href + : normalizeUrl(href), ); - const interactionProps = isTouch - ? { ...longPressHandlers } - : { - onClick: handleClick, - onMouseEnter: handleMouseEnter, - onMouseLeave: handleMouseLeave, - }; - - const linkLabel = extractLinkLabel(href); - - return ( + const linkTitleInput = ( <> - - e.preventDefault()} - target={isInternal ? undefined : "_blank"} - rel={isInternal ? undefined : "noopener noreferrer"} - > - - - - {/* Hover Toolbar */} - {isEditable && !isTouch && isHovered && !showEditPanel && ( - - - - - {isInternal ? ( - - ) : ( - - )} - - {linkLabel} - - - - - - - - - - - - - - - - - - - - )} - - {/* Edit Panel */} - {isEditable && showEditPanel && ( - <> - {createPortal( -
, - document.body, - )} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - > - - - setEditUrl(e.target.value)} - onKeyDown={handleKeyDown} - rightSection={ - editUrl && ( - setEditUrl("")} /> - ) - } - autoFocus - withAsterisk - /> - - setEditTitle(e.target.value)} - onKeyDown={handleKeyDown} - /> - - - - - - - -
- - )} - + + {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(); + }} + > + + + + + )} + + + ); } diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css index 528cbc3d..dab288c2 100644 --- a/apps/client/src/features/editor/components/link/link.module.css +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -1,51 +1,102 @@ -.link { - color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .linkWrapper { position: relative; display: inline; } -.linkText { - color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); - text-decoration: underline; - text-decoration-color: light-dark( - var(--mantine-color-blue-3), - var(--mantine-color-blue-7) - ); - text-underline-offset: 2px; - cursor: pointer; +.linkInput { + border: 1.5px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: transparent; - &:hover { - text-decoration-color: light-dark( - var(--mantine-color-blue-6), - var(--mantine-color-blue-4) + &:focus { + border-color: light-dark( + var(--mantine-color-blue-4), + var(--mantine-color-blue-6) ); + box-shadow: 0 0 0 1px + light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6)); } } -.linkToolbar { - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - z-index: 100; +.pageIcon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--mantine-color-dimmed); + font-size: 16px; + margin-top: 2px; } -.editPanel { - position: absolute; - top: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - z-index: 101; +.searchItem { + width: 100%; + padding: 7px 4px; + color: var(--mantine-color-text); + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } + } } -.editPanelOverlay { - position: fixed; - inset: 0; - z-index: 100; +.selectedSearchItem { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } +} + +.linkChip { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border-radius: var(--mantine-radius-sm); + cursor: pointer; + + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-dark-5); + } + + &:hover { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-dark-4); + } + } +} + +.removeLink { + width: 100%; + padding: 4px; + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-dark-5); + } + } } diff --git a/apps/client/src/features/editor/components/link/types.ts b/apps/client/src/features/editor/components/link/types.ts index a2cc2024..0bfedd08 100644 --- a/apps/client/src/features/editor/components/link/types.ts +++ b/apps/client/src/features/editor/components/link/types.ts @@ -1,5 +1,5 @@ export type LinkEditorPanelProps = { initialUrl?: string; - onSetLink: (url: string, openInNewTab?: boolean) => void; + onSetLink: (url: string, internal?: boolean) => void; onUnsetLink?: () => void; }; diff --git a/apps/client/src/features/editor/components/link/use-link-editor-state.tsx b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx index 778f8da7..0419ee64 100644 --- a/apps/client/src/features/editor/components/link/use-link-editor-state.tsx +++ b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx @@ -13,11 +13,16 @@ export const useLinkEditorState = ({ const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]); + const isSearchQuery = useMemo( + () => url.length > 0 && !isValidUrl, + [url, isValidUrl], + ); + const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (isValidUrl) { - onSetLink(url); + onSetLink(url, false); } }, [url, isValidUrl, onSetLink], @@ -29,5 +34,6 @@ export const useLinkEditorState = ({ onChange, handleSubmit, isValidUrl, + isSearchQuery, }; }; diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 8bcfa805..1f8861f3 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -98,12 +98,12 @@ a { color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); @mixin light { - border-bottom: 0.05em solid var(--mantine-color-dark-0); + border-bottom: 0.07em solid var(--mantine-color-dark-0); } @mixin dark { - border-bottom: 0.05em solid var(--mantine-color-dark-2); + border-bottom: 0.07em solid var(--mantine-color-dark-2); } - /*font-weight: 500; */ + font-weight: 500; text-decoration: none; cursor: pointer; } @@ -223,13 +223,13 @@ .ProseMirror > h4, .ProseMirror > h5, .ProseMirror > h6 { - + > .link-btn { cursor: pointer; position: relative; } - + > .link-btn > .link-btn-content { opacity: 0; position: absolute; @@ -241,7 +241,7 @@ justify-content: center; flex-direction: column; } - + &:hover > .link-btn > .link-btn-content { opacity: 1; } diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index 59a05c86..f536b441 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -1,4 +1,4 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query"; import { searchAttachments, searchPage, @@ -32,6 +32,7 @@ export function useSearchSuggestionsQuery( staleTime: 60 * 1000, // 1min queryFn: () => searchSuggestions(queryParams), enabled: preload || !!params.query, + placeholderData: keepPreviousData, }); } diff --git a/apps/server/src/ee b/apps/server/src/ee index 9970c21c..bc4255a5 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9970c21c810b4af5b2c78c91c666cfa099643610 +Subproject commit bc4255a585bbe2cebccdf9fa6a9fe3389be58956 diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index c8cea483..64f226a2 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -291,6 +291,7 @@ export class ExportService { prosemirrorJson, slugIdToPath, currentPagePath, + baseUrl, ); if (includeAttachments) { diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index 266141c2..ba021be3 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -62,6 +62,7 @@ export function replaceInternalLinks( prosemirrorJson: any, slugIdToPath: Record, currentPagePath: string, + baseUrl?: string, ) { const doc = jsonToNode(prosemirrorJson); @@ -76,6 +77,10 @@ export function replaceInternalLinks( const localPath = slugIdToPath[slugId]; if (!localPath) { + if (baseUrl && mark.attrs.href.startsWith('/')) { + //@ts-expect-error + mark.attrs.href = `${baseUrl}${mark.attrs.href}`; + } continue; } diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 6fc223a8..8ff1cadc 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -164,6 +164,12 @@ export class FileImportTaskService { const attachmentCandidates = await buildAttachmentCandidates(extractDir); const docmostMetadata = await readDocmostMetadata(extractDir); + const space = await this.db + .selectFrom('spaces') + .select(['slug']) + .where('id', '=', fileTask.spaceId) + .executeTakeFirst(); + const pagesMap = new Map(); for (const absPath of allFiles) { @@ -458,6 +464,7 @@ export class FileImportTaskService { creatorId: fileTask.creatorId, sourcePageId: page.id, workspaceId: fileTask.workspaceId, + spaceSlug: space?.slug, }); const pmState = getProsemirrorContent( diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 4f6a1a80..2d4bca7b 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -1,9 +1,10 @@ import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; import { Logger } from '@nestjs/common'; import * as path from 'path'; -import { v7 } from 'uuid'; import { InsertableBacklink } from '@docmost/db/types/entity.types'; import { Cheerio, CheerioAPI, load } from 'cheerio'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import slugify = require('@sindresorhus/slugify'); // Check if text contains Unicode characters (for emojis/icons) function isUnicodeCharacter(text: string): boolean { @@ -22,6 +23,7 @@ export async function formatImportHtml(opts: { workspaceId: string; pageDir?: string; attachmentCandidates?: string[]; + spaceSlug?: string; }): Promise<{ html: string; backlinks: InsertableBacklink[]; @@ -61,6 +63,7 @@ export async function formatImportHtml(opts: { creatorId, sourcePageId, workspaceId, + opts.spaceSlug, ); return { @@ -316,6 +319,7 @@ export async function rewriteInternalLinksToMentionHtml( creatorId: string, sourcePageId: string, workspaceId: string, + spaceSlug?: string, ): Promise { const normalize = (p: string) => p.replace(/\\/g, '/'); const backlinks: InsertableBacklink[] = []; @@ -339,19 +343,16 @@ export async function rewriteInternalLinksToMentionHtml( ); const meta = filePathToPageMetaMap.get(resolved); if (!meta) return; - const mentionId = v7(); - const $mention = $('') - .attr({ - 'data-type': 'mention', - 'data-id': mentionId, - 'data-entity-type': 'page', - 'data-entity-id': meta.id, - 'data-label': meta.title, - 'data-slug-id': meta.slugId, - 'data-creator-id': creatorId, - }) - .text(meta.title); - $a.replaceWith($mention); + + const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled'); + const pageSlug = `${titleSlug}-${meta.slugId}`; + const internalHref = spaceSlug + ? `/s/${spaceSlug}/p/${pageSlug}` + : `/p/${pageSlug}`; + + $a.attr('href', internalHref); + $a.attr('data-internal', 'true'); + backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId }); }); diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts index 5357e6aa..1d4cb207 100644 --- a/packages/editor-ext/src/lib/link.ts +++ b/packages/editor-ext/src/lib/link.ts @@ -6,6 +6,19 @@ import { EditorView } from "@tiptap/pm/view"; export const LinkExtension = TiptapLink.extend({ inclusive: false, + addAttributes() { + return { + ...this.parent?.(), + internal: { + default: false, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-internal') === 'true', + renderHTML: (attributes) => + attributes.internal ? { 'data-internal': 'true' } : {}, + }, + }; + }, + parseHTML() { return [ {