Skip to content

Commit 4c80af5

Browse files
committed
fix(notes): open external links on cmd+click
1 parent 01fc5ae commit 4c80af5

File tree

3 files changed

+319
-59
lines changed

3 files changed

+319
-59
lines changed

src/renderer/components/notes/NotesEditor.vue

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
useNotesEditor,
77
useTheme,
88
} from '@/composables'
9-
import { ipc } from '@/electron'
109
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
1110
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
1211
import { indentUnit } from '@codemirror/language'
@@ -22,6 +21,7 @@ import {
2221
import { GFM } from '@lezer/markdown'
2322
import { createCodeHighlight } from './cm-extensions/codeHighlight'
2423
import { editorFocusExtension } from './cm-extensions/editorFocus'
24+
import { createExternalLinksNavigation } from './cm-extensions/externalLinks'
2525
import { createHideMarkup } from './cm-extensions/hideMarkup'
2626
import {
2727
createImageBlocks,
@@ -59,63 +59,6 @@ let view: EditorView | null = null
5959
let isApplyingExternalContent = false
6060
let unregisterNavigationNoteUIState: (() => void) | undefined
6161
62-
const markdownLinkRegExp = /\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
63-
const autolinkRegExp = /<(https?:\/\/[^>\s]+|masscode:\/\/[^>\s]+)>/g
64-
const plainUrlRegExp = /(https?:\/\/[^\s)]+)/g
65-
66-
function extractUrlAtOffset(lineText: string, offset: number): string | null {
67-
for (const pattern of [markdownLinkRegExp, autolinkRegExp, plainUrlRegExp]) {
68-
pattern.lastIndex = 0
69-
let match = pattern.exec(lineText)
70-
71-
while (match) {
72-
const from = match.index
73-
const to = from + match[0].length
74-
if (offset >= from && offset <= to) {
75-
return match[1] ?? match[0]
76-
}
77-
78-
match = pattern.exec(lineText)
79-
}
80-
}
81-
82-
return null
83-
}
84-
85-
function createLinkClickHandler() {
86-
return EditorView.domEventHandlers({
87-
click(event, view) {
88-
const target = event.target
89-
if (!(target instanceof HTMLElement))
90-
return false
91-
92-
const pos = view.posAtCoords({
93-
x: event.clientX,
94-
y: event.clientY,
95-
})
96-
if (pos === null)
97-
return false
98-
99-
const line = view.state.doc.lineAt(pos)
100-
const url = extractUrlAtOffset(line.text, pos - line.from)
101-
if (!url)
102-
return false
103-
104-
if (
105-
!url.startsWith('http://')
106-
&& !url.startsWith('https://')
107-
&& !url.startsWith('masscode://')
108-
) {
109-
return false
110-
}
111-
112-
event.preventDefault()
113-
void ipc.invoke('system:open-external', url)
114-
return true
115-
},
116-
})
117-
}
118-
11962
function moveSelectionToAdjacentImageSource(
12063
view: EditorView,
12164
direction: 'up' | 'down',
@@ -285,7 +228,6 @@ function createEditorState(doc: string): EditorState {
285228
extensions.push(
286229
EditorState.readOnly.of(true),
287230
EditorView.editable.of(false),
288-
createLinkClickHandler(),
289231
)
290232
291233
if (!props.presentation) {
@@ -307,6 +249,10 @@ function createEditorState(doc: string): EditorState {
307249
extensions.push(createImageInsert())
308250
}
309251
252+
if (!raw) {
253+
extensions.push(createExternalLinksNavigation())
254+
}
255+
310256
extensions.push(
311257
EditorView.updateListener.of((update) => {
312258
if (update.docChanged && !isApplyingExternalContent) {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import {
3+
extractExternalUrlAtOffset,
4+
findExternalUrlAtCoords,
5+
handleExternalLinkClick,
6+
handleExternalLinkMouseDown,
7+
isExternalLinkNavigationEnabled,
8+
isSupportedExternalUrl,
9+
} from '../externalLinks'
10+
11+
const { invoke } = vi.hoisted(() => ({
12+
invoke: vi.fn(async () => undefined),
13+
}))
14+
15+
vi.mock('@/electron', () => ({
16+
ipc: {
17+
invoke,
18+
},
19+
}))
20+
21+
vi.mock('@/utils', () => ({
22+
isMac: true,
23+
}))
24+
25+
function createView(lineText: string, pos = 3) {
26+
return {
27+
posAtCoords: vi.fn(() => pos),
28+
state: {
29+
doc: {
30+
lineAt: vi.fn(() => ({
31+
from: 0,
32+
text: lineText,
33+
})),
34+
},
35+
},
36+
} as any
37+
}
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
})
42+
43+
describe('extractExternalUrlAtOffset', () => {
44+
it('extracts markdown link url when clicking inside the label', () => {
45+
expect(
46+
extractExternalUrlAtOffset('[Docs](https://masscode.io/docs)', 3),
47+
).toBe('https://masscode.io/docs')
48+
})
49+
50+
it('extracts plain url when clicking inside the url text', () => {
51+
expect(
52+
extractExternalUrlAtOffset('Docs https://masscode.io/docs now', 12),
53+
).toBe('https://masscode.io/docs')
54+
})
55+
56+
it('returns null when offset is outside a link', () => {
57+
expect(
58+
extractExternalUrlAtOffset('Docs https://masscode.io/docs now', 1),
59+
).toBeNull()
60+
})
61+
})
62+
63+
describe('isSupportedExternalUrl', () => {
64+
it('accepts supported protocols', () => {
65+
expect(isSupportedExternalUrl('https://masscode.io')).toBe(true)
66+
expect(isSupportedExternalUrl('http://masscode.io')).toBe(true)
67+
expect(isSupportedExternalUrl('masscode://notes-asset/example.png')).toBe(
68+
true,
69+
)
70+
})
71+
72+
it('rejects unsupported protocols', () => {
73+
expect(isSupportedExternalUrl('mailto:test@masscode.io')).toBe(false)
74+
expect(isSupportedExternalUrl('/notes/example.md')).toBe(false)
75+
})
76+
})
77+
78+
describe('isExternalLinkNavigationEnabled', () => {
79+
it('keeps external links clickable in live preview', () => {
80+
expect(isExternalLinkNavigationEnabled('livePreview')).toBe(true)
81+
})
82+
83+
it('keeps external links clickable in preview', () => {
84+
expect(isExternalLinkNavigationEnabled('preview')).toBe(true)
85+
})
86+
87+
it('does not enable navigation in raw mode', () => {
88+
expect(isExternalLinkNavigationEnabled('raw')).toBe(false)
89+
})
90+
})
91+
92+
describe('findExternalUrlAtCoords', () => {
93+
it('returns supported url for the clicked position', () => {
94+
expect(
95+
findExternalUrlAtCoords(createView('[Docs](https://masscode.io/docs)'), {
96+
x: 20,
97+
y: 10,
98+
}),
99+
).toBe('https://masscode.io/docs')
100+
})
101+
102+
it('returns null when clicked text is not an external url', () => {
103+
expect(
104+
findExternalUrlAtCoords(createView('[[Internal note]]'), {
105+
x: 20,
106+
y: 10,
107+
}),
108+
).toBeNull()
109+
})
110+
})
111+
112+
describe('external link mouse handlers', () => {
113+
it('opens the external url on cmd+mousedown', () => {
114+
const preventDefault = vi.fn()
115+
116+
const handled = handleExternalLinkMouseDown(
117+
createView('[Docs](https://masscode.io/docs)'),
118+
{
119+
clientX: 10,
120+
clientY: 20,
121+
ctrlKey: false,
122+
metaKey: true,
123+
preventDefault,
124+
} as unknown as MouseEvent,
125+
)
126+
127+
expect(handled).toBe(true)
128+
expect(preventDefault).toHaveBeenCalledTimes(1)
129+
expect(invoke).toHaveBeenCalledWith(
130+
'system:open-external',
131+
'https://masscode.io/docs',
132+
)
133+
})
134+
135+
it('does not open the external url on plain mousedown', () => {
136+
const preventDefault = vi.fn()
137+
138+
const handled = handleExternalLinkMouseDown(
139+
createView('[Docs](https://masscode.io/docs)'),
140+
{
141+
clientX: 10,
142+
clientY: 20,
143+
ctrlKey: false,
144+
metaKey: false,
145+
preventDefault,
146+
} as unknown as MouseEvent,
147+
)
148+
149+
expect(handled).toBe(false)
150+
expect(preventDefault).not.toHaveBeenCalled()
151+
expect(invoke).not.toHaveBeenCalled()
152+
})
153+
154+
it('suppresses the follow-up click for cmd+click', () => {
155+
const preventDefault = vi.fn()
156+
157+
const handled = handleExternalLinkClick(
158+
createView('[Docs](https://masscode.io/docs)'),
159+
{
160+
clientX: 10,
161+
clientY: 20,
162+
ctrlKey: false,
163+
metaKey: true,
164+
preventDefault,
165+
} as unknown as MouseEvent,
166+
)
167+
168+
expect(handled).toBe(true)
169+
expect(preventDefault).toHaveBeenCalledTimes(1)
170+
expect(invoke).not.toHaveBeenCalled()
171+
})
172+
173+
it('does not suppress plain click on external link', () => {
174+
const preventDefault = vi.fn()
175+
176+
const handled = handleExternalLinkClick(
177+
createView('[Docs](https://masscode.io/docs)'),
178+
{
179+
clientX: 10,
180+
clientY: 20,
181+
ctrlKey: false,
182+
metaKey: false,
183+
preventDefault,
184+
} as unknown as MouseEvent,
185+
)
186+
187+
expect(handled).toBe(false)
188+
expect(preventDefault).not.toHaveBeenCalled()
189+
expect(invoke).not.toHaveBeenCalled()
190+
})
191+
})

0 commit comments

Comments
 (0)