Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/web/ui/messages/message-markdown.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -79,9 +80,19 @@ export function MessageMarkdown({
"hr",
]}
components={{
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
a: ({ node, href, ...props }) =>
href ? (
<LinkHoverTooltip href={href}>
<a
{...props}
href={href}
target="_blank"
rel="noopener noreferrer"
/>
</LinkHoverTooltip>
) : (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
img: ({ node, ...props }) => <ZoomImage {...props} />,
p: ({ node, children, ...props }) => {
// Check if paragraph only contains images (which render as divs via ZoomImage)
Expand Down
201 changes: 200 additions & 1 deletion packages/ui/src/rich-text-area/index.tsx
Original file line number Diff line number Diff line change
@@ -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<EditorContentProps, "editor">) {
const { editor, isUploading } = useRichTextContext();
const { editor, editable, features, isUploading } = useRichTextContext();
const supportsLinks = features?.includes("links");

return (
<div
Expand All @@ -21,6 +29,7 @@ export function RichTextArea({
)}
>
<EditorContent editor={editor} {...rest} />
{editable !== false && supportsLinks && <RichTextAreaLinkTooltip />}

{isUploading && (
<div className="absolute inset-0 flex items-center justify-center">
Expand All @@ -30,3 +39,193 @@ export function RichTextArea({
</div>
);
}

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 (
<LinkUrlTooltipPortal href={hoveredLink.href} rect={hoveredLink.rect} />
);
}

export function LinkHoverTooltip({
href,
children,
}: {
href: string;
children: ReactNode;
}) {
const containerRef = useRef<HTMLSpanElement>(null);
const [tooltipState, setTooltipState] = useState<LinkTooltipState | null>(
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 (
<>
<span
ref={containerRef}
className="inline"
onMouseEnter={() => {
const element = containerRef.current;
if (!element) return;

setTooltipState({
href,
rect: element.getBoundingClientRect(),
});
}}
onMouseLeave={() => setTooltipState(null)}
>
{children}
</span>
{tooltipState && <LinkUrlTooltipPortal {...tooltipState} />}
</>
);
}

function LinkUrlTooltipPortal({ href, rect }: LinkTooltipState) {
if (typeof document === "undefined") {
return null;
}

const showBelow = rect.top < 72;

return createPortal(
<div
className="pointer-events-none fixed z-[100] max-w-[400px] rounded-xl border border-neutral-200 bg-white px-3 py-2 text-left text-xs leading-snug text-neutral-700 shadow-lg"
style={{
left: rect.left + rect.width / 2,
top: showBelow ? rect.bottom + 8 : rect.top - 8,
transform: showBelow ? "translateX(-50%)" : "translate(-50%, -100%)",
width: "max-content",
maxWidth: "min(400px, calc(100vw - 2rem))",
overflowWrap: "anywhere",
}}
>
{href}
</div>,
document.body,
);
}
39 changes: 38 additions & 1 deletion packages/ui/src/rich-text-area/rich-text-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +61,8 @@ export const RichTextContext = createContext<
> & {
editor: Editor | null;
isUploading: boolean;
linkEditorOpen: boolean;
setLinkEditorOpen: Dispatch<SetStateAction<boolean>>;
handleImageUpload:
| ((file: File, currentEditor: Editor, pos: number) => Promise<void>)
| null;
Expand Down Expand Up @@ -92,6 +97,7 @@ export const RichTextProvider = forwardRef<
ref,
) => {
const [isUploading, setIsUploading] = useState(false);
const [linkEditorOpen, setLinkEditorOpen] = useState(false);

const handleImageUpload = useMemo(
() =>
Expand Down Expand Up @@ -143,6 +149,8 @@ export const RichTextProvider = forwardRef<
? [
Link.extend({
inclusive: false,
}).configure({
openOnClick: false,
}),
]
: []),
Expand Down Expand Up @@ -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);
Expand All @@ -267,6 +302,8 @@ export const RichTextProvider = forwardRef<
variables,
editor,
isUploading,
linkEditorOpen,
setLinkEditorOpen,
handleImageUpload,
}}
>
Expand Down
Loading
Loading