From fb87025db0b64869c35c842a479156b063bd921f Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Fri, 10 Apr 2026 16:19:35 -0700 Subject: [PATCH 1/5] Messaging improvements --- apps/web/ui/messages/message-markdown.tsx | 17 +- packages/ui/src/rich-text-area/index.tsx | 202 +++++++++++++++++- .../src/rich-text-area/rich-text-provider.tsx | 35 +++ .../src/rich-text-area/rich-text-toolbar.tsx | 48 +++-- 4 files changed, 277 insertions(+), 25 deletions(-) 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..07bf3f9c3a6 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,194 @@ 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(), + }); + }; + + updateRect(); + 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..9cb4547b73d 100644 --- a/packages/ui/src/rich-text-area/rich-text-provider.tsx +++ b/packages/ui/src/rich-text-area/rich-text-provider.tsx @@ -12,6 +12,7 @@ import { createContext, forwardRef, useContext, + useEffect, useImperativeHandle, useMemo, useState, @@ -58,6 +59,8 @@ export const RichTextContext = createContext< > & { editor: Editor | null; isUploading: boolean; + linkEditorOpen: boolean; + setLinkEditorOpen: (open: boolean) => void; handleImageUpload: | ((file: File, currentEditor: Editor, pos: number) => Promise) | null; @@ -92,6 +95,7 @@ export const RichTextProvider = forwardRef< ref, ) => { const [isUploading, setIsUploading] = useState(false); + const [linkEditorOpen, setLinkEditorOpen] = useState(false); const handleImageUpload = useMemo( () => @@ -143,6 +147,8 @@ export const RichTextProvider = forwardRef< ? [ Link.extend({ inclusive: false, + }).configure({ + openOnClick: false, }), ] : []), @@ -252,6 +258,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 +300,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..d3edda9ad48 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,6 @@ import { cn } from "@dub/utils"; import { useEditorState } from "@tiptap/react"; -import { ReactNode, forwardRef, useRef } from "react"; +import { ReactNode, forwardRef, useEffect, useRef } from "react"; import { AtSign, Heading1, @@ -32,6 +32,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,38 +140,43 @@ export function RichTextToolbar({ } function LinkButton() { - const { editor } = useRichTextContext(); + const { editor, linkEditorOpen, setLinkEditorOpen } = useRichTextContext(); const editorState = useEditorState({ editor, selector: ({ editor }) => ({ + isLink: Boolean(editor?.isActive("link")), isSelection: editor?.state.selection.from !== editor?.state.selection.to, }), }); + useEffect(() => { + if (!editor || !linkEditorOpen) return; + + const previousUrl = editor.getAttributes("link").href; + const url = window.prompt("Link URL", previousUrl); + + if (!url?.trim()) { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + } else { + editor + .chain() + .focus() + .extendMarkRange("link") + .setLink({ href: url }) + .run(); + } + + setLinkEditorOpen(false); + }, [editor, linkEditorOpen, setLinkEditorOpen]); + 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} + isActive={editorState?.isLink} + onClick={() => setLinkEditorOpen(true)} + disabled={!editorState?.isSelection && !editorState?.isLink} /> ); } From 086c172c2a1845bbb473971d5617569e78927726 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Fri, 10 Apr 2026 16:28:01 -0700 Subject: [PATCH 2/5] URL formatting fix --- .../ui/src/rich-text-area/rich-text-toolbar.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 d3edda9ad48..04950575bf0 100644 --- a/packages/ui/src/rich-text-area/rich-text-toolbar.tsx +++ b/packages/ui/src/rich-text-area/rich-text-toolbar.tsx @@ -14,6 +14,16 @@ import { } from "../icons"; import { useRichTextContext } from "./rich-text-provider"; +function normalizeLinkUrl(url: string) { + const trimmedUrl = url.trim(); + + if (!trimmedUrl) return trimmedUrl; + if (trimmedUrl.startsWith("//")) return `https:${trimmedUrl}`; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl)) return trimmedUrl; + + return `https://${trimmedUrl}`; +} + export function RichTextToolbar({ toolsStart, toolsEnd, @@ -155,15 +165,16 @@ function LinkButton() { const previousUrl = editor.getAttributes("link").href; const url = window.prompt("Link URL", previousUrl); + const normalizedUrl = url ? normalizeLinkUrl(url) : url; - if (!url?.trim()) { + if (!normalizedUrl?.trim()) { editor.chain().focus().extendMarkRange("link").unsetLink().run(); } else { editor .chain() .focus() .extendMarkRange("link") - .setLink({ href: url }) + .setLink({ href: normalizedUrl }) .run(); } From e270e5e1bb6163718c087d0125754b6a88f6321c Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Fri, 10 Apr 2026 16:39:54 -0700 Subject: [PATCH 3/5] CR fix 1 --- packages/ui/src/rich-text-area/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/rich-text-area/index.tsx b/packages/ui/src/rich-text-area/index.tsx index 07bf3f9c3a6..63a58f13bff 100644 --- a/packages/ui/src/rich-text-area/index.tsx +++ b/packages/ui/src/rich-text-area/index.tsx @@ -173,7 +173,6 @@ export function LinkHoverTooltip({ }); }; - updateRect(); window.addEventListener("resize", updateRect); window.addEventListener("scroll", updateRect, true); @@ -181,7 +180,7 @@ export function LinkHoverTooltip({ window.removeEventListener("resize", updateRect); window.removeEventListener("scroll", updateRect, true); }; - }, [href, tooltipState]); + }, [href, !!tooltipState]); return ( <> From 96ec7ec9b0d3e4bfe39cb71a878a2068d083a9c5 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 13 Apr 2026 15:29:03 -0700 Subject: [PATCH 4/5] Fixes and updates --- .../src/rich-text-area/rich-text-provider.tsx | 6 +- .../src/rich-text-area/rich-text-toolbar.tsx | 281 ++++++++++++++++-- 2 files changed, 262 insertions(+), 25 deletions(-) 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 9cb4547b73d..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,9 +8,11 @@ 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, @@ -60,7 +62,7 @@ export const RichTextContext = createContext< editor: Editor | null; isUploading: boolean; linkEditorOpen: boolean; - setLinkEditorOpen: (open: boolean) => void; + setLinkEditorOpen: Dispatch>; handleImageUpload: | ((file: File, currentEditor: Editor, pos: number) => Promise) | null; 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 04950575bf0..1d082d577db 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, useEffect, useRef } from "react"; +import { + ReactNode, + forwardRef, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "../button"; import { AtSign, Heading1, @@ -12,6 +20,8 @@ import { TextItalic, TextStrike, } from "../icons"; +import { Input } from "../input"; +import { Modal } from "../modal"; import { useRichTextContext } from "./rich-text-provider"; function normalizeLinkUrl(url: string) { @@ -24,6 +34,52 @@ function normalizeLinkUrl(url: string) { 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; + } + + 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)) { + from -= 1; + } + + while ( + to < state.doc.content.size && + state.doc.rangeHasMark(to, to + 1, linkMark) + ) { + to += 1; + } + + return { from, to }; +} + export function RichTextToolbar({ toolsStart, toolsEnd, @@ -151,6 +207,16 @@ export function RichTextToolbar({ function LinkButton() { 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 editorState = useEditorState({ editor, @@ -160,35 +226,204 @@ function LinkButton() { }), }); + const canOpenLinkEditor = useMemo( + () => Boolean(editorState?.isSelection || editorState?.isLink), + [editorState?.isLink, editorState?.isSelection], + ); + useEffect(() => { if (!editor || !linkEditorOpen) return; - const previousUrl = editor.getAttributes("link").href; - const url = window.prompt("Link URL", previousUrl); - const normalizedUrl = url ? normalizeLinkUrl(url) : url; - - if (!normalizedUrl?.trim()) { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - } else { - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href: normalizedUrl }) - .run(); - } + 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); - }, [editor, linkEditorOpen, setLinkEditorOpen]); + }; + + 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; + + editor + .chain() + .focus() + .insertContentAt( + { + from: selectionState.from, + to: selectionState.to, + }, + nextText, + ) + .setTextSelection({ + from: selectionState.from, + to: selectionState.from + nextText.length, + }) + .setLink({ href: normalizedUrl }) + .run(); + + closeModal(); + }; return ( - setLinkEditorOpen(true)} - disabled={!editorState?.isSelection && !editorState?.isLink} - /> + <> + 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 && ( + + )} +
+
+
+
+
+ ); } From 635c55cbfe3fd51f45fbfa0906b1594d071355f9 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 13 Apr 2026 15:45:38 -0700 Subject: [PATCH 5/5] CR Fix 1 --- .../src/rich-text-area/rich-text-toolbar.tsx | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) 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 1d082d577db..1b379afc481 100644 --- a/packages/ui/src/rich-text-area/rich-text-toolbar.tsx +++ b/packages/ui/src/rich-text-area/rich-text-toolbar.tsx @@ -26,10 +26,15 @@ 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}`; - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl)) return trimmedUrl; + + const schemeMatch = trimmedUrl.match(/^([a-z][a-z0-9+.-]*):/i); + if (schemeMatch) { + return allowedSchemes.has(schemeMatch[1].toLowerCase()) ? trimmedUrl : ""; + } return `https://${trimmedUrl}`; } @@ -52,6 +57,18 @@ function getLinkRange( 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; @@ -66,13 +83,18 @@ function getLinkRange( } } - while (from > 0 && state.doc.rangeHasMark(from - 1, from, linkMark)) { + 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) + state.doc.rangeHasMark(to, to + 1, linkMark) && + getAdjacentLinkHref("right", to) === currentHref ) { to += 1; } @@ -217,6 +239,8 @@ function LinkButton() { }); 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, @@ -316,16 +340,19 @@ function LinkButton() { if (!normalizedUrl || !nextText.trim()) return; - editor - .chain() - .focus() - .insertContentAt( + 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, @@ -356,10 +383,14 @@ function LinkButton() {
-
-