|
| 1 | +import { useCallback, useEffect, useRef, useState } from 'react' |
| 2 | +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' |
| 3 | +import { getMarkRange } from '@tiptap/core' |
| 4 | +import type { Editor } from '@tiptap/react' |
| 5 | +import { Check, Copy, Pencil, Unlink } from 'lucide-react' |
| 6 | +import { normalizeLinkHref } from '../markdown-fidelity' |
| 7 | +import { ToolbarButton } from './toolbar-button' |
| 8 | + |
| 9 | +interface LinkHoverCardProps { |
| 10 | + editor: Editor |
| 11 | +} |
| 12 | + |
| 13 | +interface LinkRange { |
| 14 | + from: number |
| 15 | + to: number |
| 16 | + href: string |
| 17 | +} |
| 18 | + |
| 19 | +/** Resolves the document range and href of the link rendered by `el`, or null if it isn't a link. */ |
| 20 | +function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { |
| 21 | + const { state } = editor.view |
| 22 | + const linkType = state.schema.marks.link |
| 23 | + if (!linkType) return null |
| 24 | + const pos = editor.view.posAtDOM(el, 0) |
| 25 | + if (pos < 0) return null |
| 26 | + const range = |
| 27 | + getMarkRange(state.doc.resolve(pos), linkType) ?? |
| 28 | + getMarkRange(state.doc.resolve(pos + 1), linkType) |
| 29 | + if (!range) return null |
| 30 | + const href = el.getAttribute('href') ?? '' |
| 31 | + return { from: range.from, to: range.to, href } |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Floating card shown when hovering a link, so the destination is visible even when the link text |
| 36 | + * differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and |
| 37 | + * Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short |
| 38 | + * close delay plus the card's own hover bridge let the pointer travel from the link into the card. |
| 39 | + */ |
| 40 | +export function LinkHoverCard({ editor }: LinkHoverCardProps) { |
| 41 | + const [activeLink, setActiveLink] = useState<HTMLElement | null>(null) |
| 42 | + const [draftHref, setDraftHref] = useState<string | null>(null) |
| 43 | + const [position, setPosition] = useState<{ x: number; y: number } | null>(null) |
| 44 | + const isEditing = draftHref !== null |
| 45 | + const editInputRef = useRef<HTMLInputElement>(null) |
| 46 | + const floatingRef = useRef<HTMLDivElement>(null) |
| 47 | + const hideTimerRef = useRef<number | undefined>(undefined) |
| 48 | + |
| 49 | + // Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the |
| 50 | + // bubble menu positions through) — no React wrapper, so the harness/app share one React instance. |
| 51 | + useEffect(() => { |
| 52 | + const floating = floatingRef.current |
| 53 | + if (!activeLink || !floating) { |
| 54 | + setPosition(null) |
| 55 | + return |
| 56 | + } |
| 57 | + return autoUpdate(activeLink, floating, () => { |
| 58 | + computePosition(activeLink, floating, { |
| 59 | + strategy: 'fixed', |
| 60 | + placement: 'top', |
| 61 | + middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], |
| 62 | + }).then(({ x, y }) => setPosition({ x, y })) |
| 63 | + }) |
| 64 | + }, [activeLink]) |
| 65 | + |
| 66 | + const cancelHide = useCallback(() => window.clearTimeout(hideTimerRef.current), []) |
| 67 | + const dismiss = useCallback(() => { |
| 68 | + cancelHide() |
| 69 | + setActiveLink(null) |
| 70 | + setDraftHref(null) |
| 71 | + }, [cancelHide]) |
| 72 | + const scheduleHide = useCallback(() => { |
| 73 | + cancelHide() |
| 74 | + hideTimerRef.current = window.setTimeout(() => { |
| 75 | + setActiveLink(null) |
| 76 | + setDraftHref(null) |
| 77 | + }, 120) |
| 78 | + }, [cancelHide]) |
| 79 | + |
| 80 | + useEffect(() => { |
| 81 | + const dom = editor.view.dom |
| 82 | + const onOver = (event: Event) => { |
| 83 | + // Don't compete with the selection toolbar while text is selected. |
| 84 | + if (!editor.state.selection.empty) return |
| 85 | + const link = (event.target as HTMLElement | null)?.closest('a') |
| 86 | + if (link && dom.contains(link)) { |
| 87 | + cancelHide() |
| 88 | + setActiveLink(link) |
| 89 | + } |
| 90 | + } |
| 91 | + const onOut = (event: MouseEvent) => { |
| 92 | + const link = (event.target as HTMLElement | null)?.closest('a') |
| 93 | + if (!link) return |
| 94 | + // Ignore moves that stay within the same link. |
| 95 | + if (link.contains(event.relatedTarget as Node | null)) return |
| 96 | + scheduleHide() |
| 97 | + } |
| 98 | + dom.addEventListener('mouseover', onOver) |
| 99 | + dom.addEventListener('mouseout', onOut) |
| 100 | + return () => { |
| 101 | + dom.removeEventListener('mouseover', onOver) |
| 102 | + dom.removeEventListener('mouseout', onOut) |
| 103 | + window.clearTimeout(hideTimerRef.current) |
| 104 | + } |
| 105 | + }, [editor, cancelHide, scheduleHide]) |
| 106 | + |
| 107 | + useEffect(() => { |
| 108 | + if (isEditing) editInputRef.current?.focus() |
| 109 | + }, [isEditing]) |
| 110 | + |
| 111 | + if (!activeLink) return null |
| 112 | + |
| 113 | + const rawHref = activeLink.getAttribute('href') ?? '' |
| 114 | + const safeHref = normalizeLinkHref(rawHref) |
| 115 | + const canEdit = editor.isEditable |
| 116 | + |
| 117 | + const startEdit = () => setDraftHref(rawHref) |
| 118 | + |
| 119 | + const commitEdit = () => { |
| 120 | + const range = resolveLinkRange(editor, activeLink) |
| 121 | + if (range) { |
| 122 | + const href = normalizeLinkHref((draftHref ?? '').trim()) |
| 123 | + const chain = editor.chain().focus().setTextSelection(range).extendMarkRange('link') |
| 124 | + if (href) chain.setLink({ href }) |
| 125 | + else chain.unsetLink() |
| 126 | + chain.run() |
| 127 | + } |
| 128 | + dismiss() |
| 129 | + } |
| 130 | + |
| 131 | + const removeLink = () => { |
| 132 | + const range = resolveLinkRange(editor, activeLink) |
| 133 | + if (range) { |
| 134 | + editor.chain().focus().setTextSelection(range).extendMarkRange('link').unsetLink().run() |
| 135 | + } |
| 136 | + dismiss() |
| 137 | + } |
| 138 | + |
| 139 | + return ( |
| 140 | + <div |
| 141 | + ref={floatingRef} |
| 142 | + style={{ |
| 143 | + position: 'fixed', |
| 144 | + top: 0, |
| 145 | + left: 0, |
| 146 | + transform: position ? `translate(${position.x}px, ${position.y}px)` : undefined, |
| 147 | + visibility: position ? 'visible' : 'hidden', |
| 148 | + }} |
| 149 | + role='dialog' |
| 150 | + aria-label='Link' |
| 151 | + onMouseEnter={cancelHide} |
| 152 | + onMouseLeave={scheduleHide} |
| 153 | + className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none' |
| 154 | + > |
| 155 | + {isEditing ? ( |
| 156 | + <> |
| 157 | + <input |
| 158 | + ref={editInputRef} |
| 159 | + aria-label='Link URL' |
| 160 | + type='text' |
| 161 | + inputMode='url' |
| 162 | + value={draftHref ?? ''} |
| 163 | + onChange={(event) => setDraftHref(event.target.value)} |
| 164 | + onKeyDown={(event) => { |
| 165 | + if (event.key === 'Enter') { |
| 166 | + event.preventDefault() |
| 167 | + commitEdit() |
| 168 | + } else if (event.key === 'Escape') { |
| 169 | + event.preventDefault() |
| 170 | + setDraftHref(null) |
| 171 | + } |
| 172 | + }} |
| 173 | + placeholder='Paste or type a link…' |
| 174 | + className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' |
| 175 | + /> |
| 176 | + <ToolbarButton icon={Check} label='Apply link' onClick={commitEdit} /> |
| 177 | + </> |
| 178 | + ) : ( |
| 179 | + <> |
| 180 | + {safeHref ? ( |
| 181 | + <a |
| 182 | + href={safeHref} |
| 183 | + target='_blank' |
| 184 | + rel='noopener noreferrer' |
| 185 | + title={rawHref} |
| 186 | + className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline' |
| 187 | + > |
| 188 | + {rawHref} |
| 189 | + </a> |
| 190 | + ) : ( |
| 191 | + <span className='max-w-[260px] truncate px-2 text-[var(--text-muted)] text-small'> |
| 192 | + {rawHref} |
| 193 | + </span> |
| 194 | + )} |
| 195 | + <ToolbarButton icon={Copy} label='Copy link' onClick={() => copyToClipboard(rawHref)} /> |
| 196 | + {canEdit && <ToolbarButton icon={Pencil} label='Edit link' onClick={startEdit} />} |
| 197 | + {canEdit && <ToolbarButton icon={Unlink} label='Remove link' onClick={removeLink} />} |
| 198 | + </> |
| 199 | + )} |
| 200 | + </div> |
| 201 | + ) |
| 202 | +} |
| 203 | + |
| 204 | +function copyToClipboard(text: string) { |
| 205 | + if (text) void navigator.clipboard?.writeText(text).catch(() => {}) |
| 206 | +} |
0 commit comments