diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 49f23340..f35ec179 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 733455cf..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,36 +1,199 @@ -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, initialUrl, + 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} - /> - - + } + 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 new file mode 100644 index 00000000..90b89e82 --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -0,0 +1,583 @@ +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"; + +export const normalizeUrl = (url: string): string => { + if (!url) return url; + if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url; + return `https://${url}`; +}; + +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(); + }} + > + + + + + )} + + + ); +} 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 2168997f..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,6 +1,102 @@ -.link { - color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} \ No newline at end of file +.linkWrapper { + position: relative; + display: inline; +} + +.linkInput { + border: 1.5px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: transparent; + + &: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)); + } +} + +.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; +} + +.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); + } + } +} + +.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 853dcae6..0bfedd08 100644 --- a/apps/client/src/features/editor/components/link/types.ts +++ b/apps/client/src/features/editor/components/link/types.ts @@ -1,4 +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/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 22c595e0..0532156b 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -86,8 +86,9 @@ import fortran from "highlight.js/lib/languages/fortran"; import haskell from "highlight.js/lib/languages/haskell"; import scala from "highlight.js/lib/languages/scala"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; -import { ReactNodeViewRenderer } from "@tiptap/react"; +import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react"; import MentionView from "@/features/editor/components/mention/mention-view.tsx"; +import LinkView from "@/features/editor/components/link/link-view.tsx"; import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; @@ -176,6 +177,10 @@ export const mainExtensions = [ }), LinkExtension.configure({ openOnClick: false, + }).extend({ + addMarkView() { + return ReactMarkViewRenderer(LinkView); + }, }), Superscript, SubScript, diff --git a/apps/client/src/features/editor/hooks/use-editor-scroll.ts b/apps/client/src/features/editor/hooks/use-editor-scroll.ts index 31c357a0..cfd5a692 100644 --- a/apps/client/src/features/editor/hooks/use-editor-scroll.ts +++ b/apps/client/src/features/editor/hooks/use-editor-scroll.ts @@ -42,7 +42,7 @@ export const useEditorScroll = ({ return; } - const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); + const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`); if (dom) { dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); resolve(true); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index d0d1de03..250d47a3 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -50,7 +50,6 @@ import { handleFileDrop, handlePaste, } from "@/features/editor/components/common/editor-paste-handler.tsx"; -import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; @@ -418,7 +417,6 @@ export default function PageEditor({ - )} {showCommentPopup && } 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 cd776584..44300c3d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit cd776584695dc15f10b9339a3382b9f12d223c24 +Subproject commit 44300c3d8e9aa23f4002232f30ad27648fc04dec 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 [ {