diff --git a/apps/web/ui/messages/message-markdown.tsx b/apps/web/ui/messages/message-markdown.tsx index 9b9c1106ecc..7f2ab77319b 100644 --- a/apps/web/ui/messages/message-markdown.tsx +++ b/apps/web/ui/messages/message-markdown.tsx @@ -1,3 +1,4 @@ +import { LinkHoverTooltip } from "@dub/ui"; import { cn } from "@dub/utils"; import ReactMarkdown from "react-markdown"; import "react-medium-image-zoom/dist/styles.css"; @@ -79,9 +80,19 @@ export function MessageMarkdown({ "hr", ]} components={{ - a: ({ node, ...props }) => ( - - ), + a: ({ node, href, ...props }) => + href ? ( + + + + ) : ( + + ), img: ({ node, ...props }) => , p: ({ node, children, ...props }) => { // Check if paragraph only contains images (which render as divs via ZoomImage) diff --git a/packages/ui/src/rich-text-area/index.tsx b/packages/ui/src/rich-text-area/index.tsx index 25f7619623d..63a58f13bff 100644 --- a/packages/ui/src/rich-text-area/index.tsx +++ b/packages/ui/src/rich-text-area/index.tsx @@ -1,16 +1,24 @@ import { cn } from "@dub/utils"; import { EditorContent, EditorContentProps } from "@tiptap/react"; +import { ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { LoadingSpinner } from "../icons"; import { useRichTextContext } from "./rich-text-provider"; export * from "./rich-text-provider"; export * from "./rich-text-toolbar"; +type LinkTooltipState = { + href: string; + rect: DOMRect; +}; + export function RichTextArea({ className, ...rest }: Omit) { - const { editor, isUploading } = useRichTextContext(); + const { editor, editable, features, isUploading } = useRichTextContext(); + const supportsLinks = features?.includes("links"); return (
+ {editable !== false && supportsLinks && } {isUploading && (
@@ -30,3 +39,193 @@ export function RichTextArea({
); } + +function RichTextAreaLinkTooltip() { + const { editor, linkEditorOpen } = useRichTextContext(); + const [hoveredLink, setHoveredLink] = useState<{ + element: HTMLAnchorElement; + href: string; + rect: DOMRect; + } | null>(null); + + useEffect(() => { + if (!editor) return; + + const root = editor.view.dom as HTMLElement; + const scrollElement = + (editor.view as { scrollDOM?: HTMLElement }).scrollDOM ?? root; + + const updateHoveredLink = (link: HTMLAnchorElement | null) => { + if (!link) { + setHoveredLink(null); + return; + } + + setHoveredLink((current) => + current?.element === link + ? { + ...current, + href: link.href, + rect: link.getBoundingClientRect(), + } + : { + element: link, + href: link.href, + rect: link.getBoundingClientRect(), + }, + ); + }; + + const onMouseOver = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + + const link = event.target.closest("a[href]"); + if (!(link instanceof HTMLAnchorElement) || !root.contains(link)) return; + + updateHoveredLink(link); + }; + + const onMouseOut = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + + const currentLink = event.target.closest("a[href]"); + if (!(currentLink instanceof HTMLAnchorElement)) return; + + const nextTarget = + event.relatedTarget instanceof Element + ? event.relatedTarget.closest("a[href]") + : null; + + if (nextTarget === currentLink) return; + + setHoveredLink((current) => + current?.element === currentLink ? null : current, + ); + }; + + const syncHoveredLink = () => { + setHoveredLink((current) => { + if (!current?.element.isConnected) return null; + + return { + ...current, + href: current.element.href, + rect: current.element.getBoundingClientRect(), + }; + }); + }; + + const onMouseLeave = () => { + setHoveredLink(null); + }; + + root.addEventListener("mouseover", onMouseOver); + root.addEventListener("mouseout", onMouseOut); + root.addEventListener("mouseleave", onMouseLeave); + scrollElement.addEventListener("scroll", syncHoveredLink, { + passive: true, + }); + window.addEventListener("resize", syncHoveredLink); + + return () => { + root.removeEventListener("mouseover", onMouseOver); + root.removeEventListener("mouseout", onMouseOut); + root.removeEventListener("mouseleave", onMouseLeave); + scrollElement.removeEventListener("scroll", syncHoveredLink); + window.removeEventListener("resize", syncHoveredLink); + }; + }, [editor]); + + if (!hoveredLink || linkEditorOpen || typeof document === "undefined") { + return null; + } + + return ( + + ); +} + +export function LinkHoverTooltip({ + href, + children, +}: { + href: string; + children: ReactNode; +}) { + const containerRef = useRef(null); + const [tooltipState, setTooltipState] = useState( + null, + ); + + useEffect(() => { + if (!tooltipState) return; + + const updateRect = () => { + const element = containerRef.current; + if (!element) { + setTooltipState(null); + return; + } + + setTooltipState({ + href, + rect: element.getBoundingClientRect(), + }); + }; + + window.addEventListener("resize", updateRect); + window.addEventListener("scroll", updateRect, true); + + return () => { + window.removeEventListener("resize", updateRect); + window.removeEventListener("scroll", updateRect, true); + }; + }, [href, !!tooltipState]); + + return ( + <> + { + const element = containerRef.current; + if (!element) return; + + setTooltipState({ + href, + rect: element.getBoundingClientRect(), + }); + }} + onMouseLeave={() => setTooltipState(null)} + > + {children} + + {tooltipState && } + + ); +} + +function LinkUrlTooltipPortal({ href, rect }: LinkTooltipState) { + if (typeof document === "undefined") { + return null; + } + + const showBelow = rect.top < 72; + + return createPortal( +
+ {href} +
, + document.body, + ); +} diff --git a/packages/ui/src/rich-text-area/rich-text-provider.tsx b/packages/ui/src/rich-text-area/rich-text-provider.tsx index f39e0cfce10..236ab8c92e2 100644 --- a/packages/ui/src/rich-text-area/rich-text-provider.tsx +++ b/packages/ui/src/rich-text-area/rich-text-provider.tsx @@ -8,10 +8,13 @@ import { Markdown } from "@tiptap/markdown"; import { Editor, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { - PropsWithChildren, createContext, + Dispatch, forwardRef, + PropsWithChildren, + SetStateAction, useContext, + useEffect, useImperativeHandle, useMemo, useState, @@ -58,6 +61,8 @@ export const RichTextContext = createContext< > & { editor: Editor | null; isUploading: boolean; + linkEditorOpen: boolean; + setLinkEditorOpen: Dispatch>; handleImageUpload: | ((file: File, currentEditor: Editor, pos: number) => Promise) | null; @@ -92,6 +97,7 @@ export const RichTextProvider = forwardRef< ref, ) => { const [isUploading, setIsUploading] = useState(false); + const [linkEditorOpen, setLinkEditorOpen] = useState(false); const handleImageUpload = useMemo( () => @@ -143,6 +149,8 @@ export const RichTextProvider = forwardRef< ? [ Link.extend({ inclusive: false, + }).configure({ + openOnClick: false, }), ] : []), @@ -252,6 +260,33 @@ export const RichTextProvider = forwardRef< immediatelyRender: false, }); + useEffect(() => { + if (!editor || editable === false || !features.includes("links")) return; + + const root = editor.view.dom as HTMLElement; + + const onClick = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return; + + const link = event.target.closest("a[href]"); + if (!link || !root.contains(link)) return; + + event.preventDefault(); + event.stopPropagation(); + + requestAnimationFrame(() => { + editor.commands.focus(); + setLinkEditorOpen(true); + }); + }; + + root.addEventListener("click", onClick, true); + + return () => { + root.removeEventListener("click", onClick, true); + }; + }, [editor, editable, features]); + useImperativeHandle(ref, () => ({ setContent: (content: any) => { editor?.commands.setContent(content); @@ -267,6 +302,8 @@ export const RichTextProvider = forwardRef< variables, editor, isUploading, + linkEditorOpen, + setLinkEditorOpen, handleImageUpload, }} > diff --git a/packages/ui/src/rich-text-area/rich-text-toolbar.tsx b/packages/ui/src/rich-text-area/rich-text-toolbar.tsx index 6ebc9c74b9f..1b379afc481 100644 --- a/packages/ui/src/rich-text-area/rich-text-toolbar.tsx +++ b/packages/ui/src/rich-text-area/rich-text-toolbar.tsx @@ -1,6 +1,14 @@ import { cn } from "@dub/utils"; import { useEditorState } from "@tiptap/react"; -import { ReactNode, forwardRef, useRef } from "react"; +import { + ReactNode, + forwardRef, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "../button"; import { AtSign, Heading1, @@ -12,8 +20,88 @@ import { TextItalic, TextStrike, } from "../icons"; +import { Input } from "../input"; +import { Modal } from "../modal"; import { useRichTextContext } from "./rich-text-provider"; +function normalizeLinkUrl(url: string) { + const trimmedUrl = url.trim(); + const allowedSchemes = new Set(["http", "https", "mailto"]); + + if (!trimmedUrl) return trimmedUrl; + if (trimmedUrl.startsWith("//")) return `https:${trimmedUrl}`; + + const schemeMatch = trimmedUrl.match(/^([a-z][a-z0-9+.-]*):/i); + if (schemeMatch) { + return allowedSchemes.has(schemeMatch[1].toLowerCase()) ? trimmedUrl : ""; + } + + return `https://${trimmedUrl}`; +} + +type LinkSelectionState = { + from: number; + to: number; + text: string; + href: string; + isLink: boolean; +}; + +function getLinkRange( + editor: NonNullable["editor"]>, +) { + const { state } = editor; + const linkMark = state.schema.marks.link; + + if (!linkMark || !editor.isActive("link")) { + return null; + } + + const currentHref = editor.getAttributes("link").href; + + const getAdjacentLinkHref = (side: "left" | "right", pos: number) => { + const $pos = state.doc.resolve(pos); + const mark = + side === "left" + ? $pos.nodeBefore?.marks.find((mark) => mark.type === linkMark) + : $pos.nodeAfter?.marks.find((mark) => mark.type === linkMark); + + return mark?.attrs.href; + }; + + let from = state.selection.from; + let to = state.selection.to; + + if (from === to) { + if (from > 0 && state.doc.rangeHasMark(from - 1, from, linkMark)) { + from -= 1; + } else if ( + to < state.doc.content.size && + state.doc.rangeHasMark(to, to + 1, linkMark) + ) { + to += 1; + } + } + + while ( + from > 0 && + state.doc.rangeHasMark(from - 1, from, linkMark) && + getAdjacentLinkHref("left", from) === currentHref + ) { + from -= 1; + } + + while ( + to < state.doc.content.size && + state.doc.rangeHasMark(to, to + 1, linkMark) && + getAdjacentLinkHref("right", to) === currentHref + ) { + to += 1; + } + + return { from, to }; +} + export function RichTextToolbar({ toolsStart, toolsEnd, @@ -32,6 +120,7 @@ export function RichTextToolbar({ isBold: Boolean(editor?.isActive("bold")), isItalic: Boolean(editor?.isActive("italic")), isStrike: Boolean(editor?.isActive("strike")), + isLink: Boolean(editor?.isActive("link")), isHeading1: Boolean(editor?.isActive("heading", { level: 1 })), isHeading2: Boolean(editor?.isActive("heading", { level: 2 })), isSelection: editor?.state.selection.from !== editor?.state.selection.to, @@ -139,39 +228,237 @@ export function RichTextToolbar({ } function LinkButton() { - const { editor } = useRichTextContext(); + const { editor, linkEditorOpen, setLinkEditorOpen } = useRichTextContext(); + const linkInputRef = useRef(null); + const [selectionState, setSelectionState] = useState({ + from: 0, + to: 0, + text: "", + href: "", + isLink: false, + }); + const [textValue, setTextValue] = useState(""); + const [urlValue, setUrlValue] = useState(""); + const textInputId = "rich-text-link-text-input"; + const linkInputId = "rich-text-link-url-input"; const editorState = useEditorState({ editor, selector: ({ editor }) => ({ + isLink: Boolean(editor?.isActive("link")), isSelection: editor?.state.selection.from !== editor?.state.selection.to, }), }); + const canOpenLinkEditor = useMemo( + () => Boolean(editorState?.isSelection || editorState?.isLink), + [editorState?.isLink, editorState?.isSelection], + ); + + useEffect(() => { + if (!editor || !linkEditorOpen) return; + + const { selection, doc } = editor.state; + const linkRange = getLinkRange(editor); + const from = linkRange?.from ?? selection.from; + const to = linkRange?.to ?? selection.to; + const text = doc.textBetween(from, to, "\n"); + const href = editor.getAttributes("link").href ?? ""; + + setSelectionState({ + from, + to, + text, + href, + isLink: Boolean(linkRange), + }); + setTextValue(text); + setUrlValue(href); + }, [editor, linkEditorOpen]); + + useEffect(() => { + if (!linkEditorOpen) return; + + requestAnimationFrame(() => { + linkInputRef.current?.focus(); + linkInputRef.current?.select(); + }); + }, [linkEditorOpen]); + + useEffect(() => { + if (!editor) return; + + const root = editor.view.dom as HTMLElement; + + const onKeyDown = (event: KeyboardEvent) => { + if ( + !(event.metaKey || event.ctrlKey) || + event.key.toLowerCase() !== "k" + ) { + return; + } + + if (!canOpenLinkEditor) return; + + event.preventDefault(); + event.stopPropagation(); + setLinkEditorOpen(true); + }; + + root.addEventListener("keydown", onKeyDown); + + return () => { + root.removeEventListener("keydown", onKeyDown); + }; + }, [canOpenLinkEditor, editor, setLinkEditorOpen]); + + const closeModal = () => { + setLinkEditorOpen(false); + }; + + const deleteLink = () => { + if (!editor) return; + + editor + .chain() + .focus() + .setTextSelection({ + from: selectionState.from, + to: selectionState.to, + }) + .unsetLink() + .run(); + + closeModal(); + }; + + const saveLink = () => { + if (!editor) return; + + const normalizedUrl = normalizeLinkUrl(urlValue); + const nextText = textValue; + + if (!normalizedUrl || !nextText.trim()) return; + + const chain = editor.chain().focus(); + + if (selectionState.text !== nextText) { + chain.insertContentAt( + { + from: selectionState.from, + to: selectionState.to, + }, + nextText, + ); + } + + chain + .setTextSelection({ + from: selectionState.from, + to: selectionState.from + nextText.length, + }) + .setLink({ href: normalizedUrl }) + .run(); + + closeModal(); + }; + return ( - { - if (!editor) return; - const previousUrl = editor.getAttributes("link").href; - - const url = window.prompt("Link URL", previousUrl); - - if (!url?.trim()) { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - return; - } - - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href: url }) - .run(); - }} - disabled={!editorState?.isSelection} - /> + <> + setLinkEditorOpen(true)} + disabled={!canOpenLinkEditor} + /> + + +
+

+ {selectionState.isLink ? "Edit link" : "Add link"} +

+
+ +
+
+
+ + setTextValue(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + + event.preventDefault(); + saveLink(); + }} + className="max-w-none" + /> +
+ +
+ + setUrlValue(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + + event.preventDefault(); + saveLink(); + }} + placeholder="https://example.com" + className="max-w-none" + /> +
+
+
+ +
+
+ {selectionState.isLink && ( + + )} +
+
+
+
+
+ ); }