Skip to content

Commit 138a0c9

Browse files
committed
feat(rich-md-editor): add a link hover card and claim Cmd+K for the link shortcut
1 parent 98377cb commit 138a0c9

2 files changed

Lines changed: 210 additions & 1 deletion

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from './markdown-fidelity'
2323
import { parseMarkdownToDoc } from './markdown-parse'
2424
import { EditorBubbleMenu } from './menus/bubble-menu'
25+
import { LinkHoverCard } from './menus/link-hover-card'
2526
import { isRoundTripSafe } from './round-trip-safety'
2627
import '@/components/emcn/components/code/code.css'
2728
import './rich-markdown-editor.css'
@@ -205,7 +206,8 @@ export function LoadedRichMarkdownEditor({
205206
shouldRerenderOnTransaction: false,
206207
content: initialContent,
207208
editorProps: {
208-
attributes: { class: 'rich-markdown-prose' },
209+
// Claim Mod+K so the global command registry yields it to the editor's link shortcut.
210+
attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' },
209211
handleKeyDown: (_view, event) => {
210212
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'
211213
if (!isSaveShortcut) return false
@@ -350,6 +352,7 @@ export function LoadedRichMarkdownEditor({
350352
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
351353
>
352354
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
355+
{editor && <LinkHoverCard editor={editor} />}
353356
<EditorContent
354357
editor={editor}
355358
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'

0 commit comments

Comments
 (0)