fix(editor): open the link that received the modifier-click (#35071)

* fix(editor): open the link that received the modifier-click

* fix(editor): read clicked link href from its mark, not the sanitized DOM

Tiptap blanks rendered hrefs for file:, Windows drive paths and custom schemes; resolve the clicked anchor's document position and read the link mark instead.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 17:14:35 -07:00
committed by GitHub
co-authored by OrcaWin
parent 165f63a02d
commit 32060decbb
2 changed files with 116 additions and 16 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/react'
import { Editor, type JSONContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { handleRichMarkdownEditorClick } from './rich-markdown-editor-click-routing'
@@ -71,26 +71,40 @@ function clickLink({
isMac = true,
shiftKey = true,
modKey = true,
href = 'https://example.com/docs'
href = 'https://example.com/docs',
content,
pos = 2,
targetSelector = 'a'
}: {
sourceOwner?: HttpLinkSourceOwner
isMac?: boolean
shiftKey?: boolean
modKey?: boolean
href?: string
content?: string | JSONContent
pos?: number
targetSelector?: string
} = {}): boolean {
editor = new Editor({
extensions: [StarterKit],
content: `<p><a href="${href}">example</a></p>`
content: content ?? `<p><a href="${href}">example</a></p>`
})
const event = new MouseEvent('click', {
metaKey: isMac && modKey,
ctrlKey: !isMac && modKey,
shiftKey,
cancelable: true
})
event.preventDefault()
const target = editor.view.dom.querySelector(targetSelector)
if (!target) {
throw new Error(`Missing click target: ${targetSelector}`)
}
target.dispatchEvent(event)
return handleRichMarkdownEditorClick({
activateMarkdownLink,
editorRef: { current: editor },
event: new MouseEvent('click', {
metaKey: isMac && modKey,
ctrlKey: !isMac && modKey,
shiftKey
}),
event,
filePath: '/repo/docs/README.md',
isMac,
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
@@ -102,7 +116,7 @@ function clickLink({
markdownCommentsRef: { current: [] },
markdownSourceLineOffsetRef: { current: 0 },
onOpenDocLinkRef: { current: undefined },
pos: 2,
pos,
rootRef: { current: null },
scrollRichMarkdownReviewNoteCardIntoView: vi.fn(),
view: editor.view,
@@ -223,3 +237,75 @@ describe('rich Markdown alternate browser click', () => {
expect(openFileUri).not.toHaveBeenCalled()
})
})
describe('rich Markdown link click targets', () => {
it('opens the first character after plain text', () => {
expect(
clickLink({
shiftKey: false,
content: '<p>plain <a href="child.md">X</a> tail</p>',
pos: 7
})
).toBe(true)
expect(activateMarkdownLink).toHaveBeenCalledWith('child.md', expect.any(Object))
})
it('opens the clicked adjacent link at their shared text position', () => {
expect(
clickLink({
shiftKey: false,
content: '<p><a href="first.md">A</a><a href="second.md">B</a></p>',
targetSelector: 'a:last-child',
pos: 2
})
).toBe(true)
expect(activateMarkdownLink).toHaveBeenCalledWith('second.md', expect.any(Object))
})
it('opens a link from nested formatting at its leading boundary', () => {
expect(
clickLink({
shiftKey: false,
content: '<p>plain <a href="child.md"><strong>X</strong></a></p>',
targetSelector: 'strong',
pos: 7
})
).toBe(true)
expect(activateMarkdownLink).toHaveBeenCalledWith('child.md', expect.any(Object))
})
it('keeps neighboring prose inert even when its caret position has a link mark', () => {
expect(
clickLink({
shiftKey: false,
content: '<p><a href="child.md">X</a><em>tail</em></p>',
targetSelector: 'em',
pos: 2
})
).toBe(false)
expect(activateMarkdownLink).not.toHaveBeenCalled()
})
it.each(['file:///repo/docs/child.md', 'C:\\repo\\docs\\child.md'])(
'opens %s even though the rendered anchor drops its href',
(href) => {
expect(
clickLink({
shiftKey: false,
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'example', marks: [{ type: 'link', attrs: { href } }] }
]
}
]
}
})
).toBe(true)
expect(activateMarkdownLink).toHaveBeenCalledWith(href, expect.any(Object))
}
)
})
@@ -114,7 +114,7 @@ export function handleRichMarkdownEditorClick({
const href =
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
? String(clickedNode.attrs.href ?? '')
: getClickedLinkHref(view, pos)
: getClickedLinkHref(view, event)
if (
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
!classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
@@ -179,12 +179,26 @@ function activateMarkdownImageClick({
return true
}
function getClickedLinkHref(view: EditorView, pos: number): string {
const linkMark = view.state.doc
.resolve(pos)
.marks()
.find((mark) => mark.type.name === 'link')
return linkMark ? (linkMark.attrs.href as string) || '' : ''
function getClickedLinkHref(view: EditorView, event: MouseEvent): string {
const target =
event.target instanceof Element
? event.target
: event.target instanceof Node
? event.target.parentElement
: null
const link = target?.closest('a')
if (!link || !view.dom.contains(link)) {
return ''
}
// The rendered href is blanked for file:, drive-path and custom schemes, so read the mark instead.
let linkPos: number
try {
linkPos = view.posAtDOM(link, 0)
} catch {
return ''
}
const linkMark = view.state.doc.nodeAt(linkPos)?.marks.find((mark) => mark.type.name === 'link')
return linkMark ? String(linkMark.attrs.href ?? '') : ''
}
function openShiftModifiedMarkdownLink({