From 32060decbbb7424e4a4d3ffe01844c861da4d453 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 17:14:35 -0700 Subject: [PATCH] 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> --- ...rich-markdown-editor-click-routing.test.ts | 104 ++++++++++++++++-- .../rich-markdown-editor-click-routing.ts | 28 +++-- 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts index b0aed95a521..cbf1b5ed5cc 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts @@ -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: `

example

` + content: content ?? `

example

` }) + 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: '

plain X tail

', + 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: '

AB

', + 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: '

plain X

', + 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: '

Xtail

', + 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)) + } + ) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts index d52df08d8d9..60c7574b7a6 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts @@ -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({