+ {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"}
+
+
+
+
+
+
+
+ {selectionState.isLink && (
+
+ )}
+
+
+
+
+
+
+
+ >
);
}