import { useState, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { useTiptap } from "@tiptap/react"; import { useFloating, offset, shift, flip, autoUpdate, FloatingPortal, safePolygon, useHover, useInteractions, } from "@floating-ui/react"; import { ExternalLink, Pencil, Unlink, Check, X } from "lucide-react"; import { useEditorAffordance } from "../editor-affordance-context"; import { useEditorRectPopover } from "./menu/popover"; import { ToolbarButton } from "./toolbar-button"; import "../styles/floating-controls.css"; import "../styles/link-toolbar.css"; interface LinkState { href: string; element: HTMLAnchorElement; mode: "cursor" | "hover"; } export function LinkToolbar() { const { editor } = useTiptap(); const { documentEditable } = useEditorAffordance(); const [link, setLink] = useState(null); const [editing, setEditing] = useState(false); const [editHref, setEditHref] = useState(""); const hoverTimeoutRef = useRef | null>(null); const linkRef = useRef(link); useLayoutEffect(() => { linkRef.current = link; }, [link]); const hoverHandleClose = useMemo(() => safePolygon({ buffer: 2, requireIntent: false }), []); const open = link !== null; const { context, refs } = useFloating({ open, onOpenChange(nextOpen) { if (!nextOpen && linkRef.current?.mode === "hover") { setLink(null); setEditing(false); } }, placement: "top-start", middleware: [offset(10), flip({ padding: 10 }), shift({ padding: 10 })], whileElementsMounted: autoUpdate, }); const hover = useHover(context, { enabled: link?.mode === "hover", handleClose: hoverHandleClose, }); const { getFloatingProps } = useInteractions([hover]); const { floatingStyles, setFloating } = useEditorRectPopover({ open, onClose: () => setLink(null), contextElement: () => (link?.element?.isConnected ? link.element : null), getAnchorRect: () => (link?.element?.isConnected ? link.element.getBoundingClientRect() : null), placement: "top-start", offset: 10, padding: 10, }); useEffect(() => { refs.setReference(link?.element ?? null); }, [link?.element, refs]); useEffect(() => { const handleSelectionUpdate = () => { if (!editor.isEditable) return; const { from, to } = editor.state.selection; if (from !== to) { if (linkRef.current?.mode === "cursor") { setLink(null); setEditing(false); } return; } const linkMark = editor.getAttributes("link"); if (linkMark.href) { const domAtPos = editor.view.domAtPos(from); const el = domAtPos.node instanceof HTMLElement ? domAtPos.node : domAtPos.node.parentElement; const anchor = el?.closest("a"); if (anchor instanceof HTMLAnchorElement) { setLink({ href: linkMark.href, element: anchor, mode: "cursor" }); setEditing(false); return; } } if (linkRef.current?.mode === "cursor") { setLink(null); setEditing(false); } }; editor.on("selectionUpdate", handleSelectionUpdate); return () => { editor.off("selectionUpdate", handleSelectionUpdate); }; }, [editor]); useEffect(() => { const dom = editor.view.dom; const handleMouseOver = (e: MouseEvent) => { if (linkRef.current?.mode === "cursor") return; const target = e.target instanceof HTMLElement ? e.target : null; const anchor = target?.closest("a"); if (!(anchor instanceof HTMLAnchorElement)) return; if (linkRef.current?.element === anchor && linkRef.current?.mode === "hover") return; if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = setTimeout(() => { const href = anchor.getAttribute("href"); if (href) { setLink({ href, element: anchor, mode: "hover" }); setEditing(false); } }, 250); }; const handleMouseOut = (e: MouseEvent) => { if (linkRef.current?.mode === "cursor") return; if (!hoverTimeoutRef.current) return; const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null; if (related?.closest("a")) return; clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; }; dom.addEventListener("mouseover", handleMouseOver); dom.addEventListener("mouseout", handleMouseOut); return () => { dom.removeEventListener("mouseover", handleMouseOver); dom.removeEventListener("mouseout", handleMouseOut); if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current); }; }, [editor]); const selectLink = () => { if (!link) return; const pos = editor.view.posAtDOM(link.element, 0); editor.chain().focus(null, { scrollIntoView: false }).setTextSelection(pos).extendMarkRange("link").run(); }; const handleEdit = () => { setEditHref(link?.href ?? ""); setEditing(true); }; const handleSave = () => { if (editHref) { selectLink(); editor.chain().focus(null, { scrollIntoView: false }).extendMarkRange("link").setLink({ href: editHref }).run(); } setEditing(false); setLink(null); }; const handleRemove = () => { selectLink(); editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run(); setLink(null); }; const handleOpen = () => { if (link?.href) window.open(link.href, "_blank", "noopener"); }; if (!editor || !documentEditable || !open) return null; return (
{ refs.setFloating(node); setFloating(node); }} style={{ ...floatingStyles, zIndex: 50 }} className="tiptap-link-toolbar" {...getFloatingProps({ onMouseDownCapture: (e) => { if (!(e.target instanceof HTMLInputElement)) e.preventDefault(); }, })} > {editing ? (
setEditHref(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") setEditing(false); }} className="tiptap-link-input" autoFocus /> setEditing(false)}>
) : (
{link.href}
)}
); }