Skip to content
File

Blob: src/client/components/editor/controllers/link-toolbar.tsx

typescript242 lines
1import { useState, useEffect, useLayoutEffect, useMemo, useRef } from "react";
2import { useTiptap } from "@tiptap/react";
3import {
4 useFloating,
5 offset,
6 shift,
7 flip,
8 autoUpdate,
9 FloatingPortal,
10 safePolygon,
11 useHover,
12 useInteractions,
13} from "@floating-ui/react";
14import { ExternalLink, Pencil, Unlink, Check, X } from "lucide-react";
15import { useEditorAffordance } from "../editor-affordance-context";
16import { useEditorRectPopover } from "./menu/popover";
17import { ToolbarButton } from "./toolbar-button";
18import "../styles/floating-controls.css";
19import "../styles/link-toolbar.css";
20 
21interface LinkState {
22 href: string;
23 element: HTMLAnchorElement;
24 mode: "cursor" | "hover";
25}
26 
27export function LinkToolbar() {
28 const { editor } = useTiptap();
29 const { documentEditable } = useEditorAffordance();
30 const [link, setLink] = useState<LinkState | null>(null);
31 const [editing, setEditing] = useState(false);
32 const [editHref, setEditHref] = useState("");
33 const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34 const linkRef = useRef(link);
35 useLayoutEffect(() => {
36 linkRef.current = link;
37 }, [link]);
38 const hoverHandleClose = useMemo(() => safePolygon({ buffer: 2, requireIntent: false }), []);
39 
40 const open = link !== null;
41 
42 const { context, refs } = useFloating({
43 open,
44 onOpenChange(nextOpen) {
45 if (!nextOpen && linkRef.current?.mode === "hover") {
46 setLink(null);
47 setEditing(false);
48 }
49 },
50 placement: "top-start",
51 middleware: [offset(10), flip({ padding: 10 }), shift({ padding: 10 })],
52 whileElementsMounted: autoUpdate,
53 });
54 
55 const hover = useHover(context, {
56 enabled: link?.mode === "hover",
57 handleClose: hoverHandleClose,
58 });
59 const { getFloatingProps } = useInteractions([hover]);
60 
61 const { floatingStyles, setFloating } = useEditorRectPopover({
62 open,
63 onClose: () => setLink(null),
64 contextElement: () => (link?.element?.isConnected ? link.element : null),
65 getAnchorRect: () => (link?.element?.isConnected ? link.element.getBoundingClientRect() : null),
66 placement: "top-start",
67 offset: 10,
68 padding: 10,
69 });
70 
71 useEffect(() => {
72 refs.setReference(link?.element ?? null);
73 }, [link?.element, refs]);
74 
75 useEffect(() => {
76 const handleSelectionUpdate = () => {
77 if (!editor.isEditable) return;
78 
79 const { from, to } = editor.state.selection;
80 if (from !== to) {
81 if (linkRef.current?.mode === "cursor") {
82 setLink(null);
83 setEditing(false);
84 }
85 return;
86 }
87 
88 const linkMark = editor.getAttributes("link");
89 if (linkMark.href) {
90 const domAtPos = editor.view.domAtPos(from);
91 const el = domAtPos.node instanceof HTMLElement ? domAtPos.node : domAtPos.node.parentElement;
92 const anchor = el?.closest("a");
93 if (anchor instanceof HTMLAnchorElement) {
94 setLink({ href: linkMark.href, element: anchor, mode: "cursor" });
95 setEditing(false);
96 return;
97 }
98 }
99 
100 if (linkRef.current?.mode === "cursor") {
101 setLink(null);
102 setEditing(false);
103 }
104 };
105 
106 editor.on("selectionUpdate", handleSelectionUpdate);
107 return () => {
108 editor.off("selectionUpdate", handleSelectionUpdate);
109 };
110 }, [editor]);
111 
112 useEffect(() => {
113 const dom = editor.view.dom;
114 
115 const handleMouseOver = (e: MouseEvent) => {
116 if (linkRef.current?.mode === "cursor") return;
117 
118 const target = e.target instanceof HTMLElement ? e.target : null;
119 const anchor = target?.closest("a");
120 if (!(anchor instanceof HTMLAnchorElement)) return;
121 
122 if (linkRef.current?.element === anchor && linkRef.current?.mode === "hover") return;
123 
124 if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
125 hoverTimeoutRef.current = setTimeout(() => {
126 const href = anchor.getAttribute("href");
127 if (href) {
128 setLink({ href, element: anchor, mode: "hover" });
129 setEditing(false);
130 }
131 }, 250);
132 };
133 
134 const handleMouseOut = (e: MouseEvent) => {
135 if (linkRef.current?.mode === "cursor") return;
136 if (!hoverTimeoutRef.current) return;
137 
138 const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
139 if (related?.closest("a")) return;
140 
141 clearTimeout(hoverTimeoutRef.current);
142 hoverTimeoutRef.current = null;
143 };
144 
145 dom.addEventListener("mouseover", handleMouseOver);
146 dom.addEventListener("mouseout", handleMouseOut);
147 
148 return () => {
149 dom.removeEventListener("mouseover", handleMouseOver);
150 dom.removeEventListener("mouseout", handleMouseOut);
151 if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
152 };
153 }, [editor]);
154 
155 const selectLink = () => {
156 if (!link) return;
157 const pos = editor.view.posAtDOM(link.element, 0);
158 editor.chain().focus(null, { scrollIntoView: false }).setTextSelection(pos).extendMarkRange("link").run();
159 };
160 
161 const handleEdit = () => {
162 setEditHref(link?.href ?? "");
163 setEditing(true);
164 };
165 
166 const handleSave = () => {
167 if (editHref) {
168 selectLink();
169 editor.chain().focus(null, { scrollIntoView: false }).extendMarkRange("link").setLink({ href: editHref }).run();
170 }
171 setEditing(false);
172 setLink(null);
173 };
174 
175 const handleRemove = () => {
176 selectLink();
177 editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run();
178 setLink(null);
179 };
180 
181 const handleOpen = () => {
182 if (link?.href) window.open(link.href, "_blank", "noopener");
183 };
184 
185 if (!editor || !documentEditable || !open) return null;
186 
187 return (
188 <FloatingPortal>
189 <div
190 ref={(node) => {
191 refs.setFloating(node);
192 setFloating(node);
193 }}
194 style={{ ...floatingStyles, zIndex: 50 }}
195 className="tiptap-link-toolbar"
196 {...getFloatingProps({
197 onMouseDownCapture: (e) => {
198 if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
199 },
200 })}
201 >
202 {editing ? (
203 <div className="flex items-center gap-1.5">
204 <input
205 type="text"
206 value={editHref}
207 onChange={(e) => setEditHref(e.target.value)}
208 onKeyDown={(e) => {
209 if (e.key === "Enter") handleSave();
210 if (e.key === "Escape") setEditing(false);
211 }}
212 className="tiptap-link-input"
213 autoFocus
214 />
215 <ToolbarButton title="Save" onActivate={handleSave}>
216 <Check size={14} />
217 </ToolbarButton>
218 <ToolbarButton title="Cancel" onActivate={() => setEditing(false)}>
219 <X size={14} />
220 </ToolbarButton>
221 </div>
222 ) : (
223 <div className="flex items-center gap-1.5">
224 <span className="tiptap-link-url" title={link.href}>
225 {link.href}
226 </span>
227 <ToolbarButton title="Edit link" onActivate={handleEdit}>
228 <Pencil size={14} />
229 </ToolbarButton>
230 <ToolbarButton title="Open link" onActivate={handleOpen}>
231 <ExternalLink size={14} />
232 </ToolbarButton>
233 <ToolbarButton title="Remove link" onActivate={handleRemove}>
234 <Unlink size={14} />
235 </ToolbarButton>
236 </div>
237 )}
238 </div>
239 </FloatingPortal>
240 );
241}