Skip to content

Commit 2e358ad

Browse files
authored
Improve cmd-click to handle references too (#898)
1 parent 355d539 commit 2e358ad

File tree

14 files changed

+207
-50
lines changed

14 files changed

+207
-50
lines changed

CoreEditor/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const config = import.meta.env.PROD ? window.config : {
5151
indentBehavior: 'paragraph',
5252
localizable: {
5353
previewButtonTitle: 'Preview',
54-
cmdClickToOpenLink: '⌘-click to open link',
54+
cmdClickToFollow: '⌘-click to follow',
5555
cmdClickToToggleTodo: '⌘-click to toggle todo',
5656
},
5757
} as Config;

CoreEditor/src/bridge/native/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface NativeModuleCore extends NativeModule {
1616
notifyContentOffsetDidChange(): void;
1717
notifyCompositionEnded({ selectedLineColumn }: { selectedLineColumn: LineColumnInfo }): void;
1818
notifyLinkClicked({ link }: { link: string }): void;
19+
notifyLightWarning(): void;
1920
}

CoreEditor/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface Localizable {
1616
unfoldLine: string;
1717
// Others
1818
previewButtonTitle: string;
19-
cmdClickToOpenLink: string;
19+
cmdClickToFollow: string;
2020
cmdClickToToggleTodo: string;
2121
}
2222

CoreEditor/src/modules/lezer/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,39 @@
1+
import { EditorState } from '@codemirror/state';
2+
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
3+
import { SyntaxNodeRef } from '@lezer/common';
14
import { parser as htmlParser } from '@lezer/html';
25
import { parser as markdownParser } from '@lezer/markdown';
36
import { replaceRange } from '../../common/utils';
47
import { takePossibleNewline } from '../lineEndings';
58

9+
export function getSyntaxTree(state: EditorState, sizeLimit = 102400) {
10+
const length = state.doc.length;
11+
// When the doc is small enough (default 100 KB), we can safely try getting a parse tree
12+
if (length < sizeLimit) {
13+
return ensureSyntaxTree(state, length) ?? syntaxTree(state);
14+
}
15+
16+
// Note that, it's not going to iterate the entire tree (might not have been parsed).
17+
//
18+
// This is by design because of potential performance issues.
19+
return syntaxTree(state);
20+
}
21+
22+
export function getNodesNamed(state: EditorState, nodeName: string) {
23+
const nodes: SyntaxNodeRef[] = [];
24+
25+
getSyntaxTree(state).iterate({
26+
from: 0, to: state.doc.length,
27+
enter: node => {
28+
if (node.name === nodeName) {
29+
nodes.push(node.node);
30+
}
31+
},
32+
});
33+
34+
return nodes;
35+
}
36+
637
export function extractComments(source: string) {
738
// Fail fast since we cannot find an open tag of comments
839
if (!source.includes('<!--')) {

CoreEditor/src/modules/toc/index.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { KeyBinding } from '@codemirror/view';
22
import { EditorSelection } from '@codemirror/state';
3-
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
43
import { HeadingInfo } from './types';
54
import { frontMatterRange } from '../frontMatter';
5+
import { getSyntaxTree } from '../lezer';
66
import { scrollToSelection } from '../selection';
77
import { saveGoBackSelection } from '../selection/navigate';
88
import selectWithRanges from '../selection/selectWithRanges';
@@ -32,20 +32,7 @@ export function getTableOfContents() {
3232
const state = editor.state;
3333
const results: HeadingInfo[] = [];
3434

35-
const tree = (() => {
36-
const length = state.doc.length;
37-
// When the doc is small enough (100 KB), we can safely try getting a parse tree
38-
if (length < 100 * 1024) {
39-
return ensureSyntaxTree(state, length) ?? syntaxTree(state);
40-
}
41-
42-
// Note that, it's not going to iterate the entire tree (might not have been parsed).
43-
//
44-
// This is by design because of potential performance issues.
45-
return syntaxTree(state);
46-
})();
47-
48-
tree.iterate({
35+
getSyntaxTree(state).iterate({
4936
from: 0, to: state.doc.length,
5037
enter: node => {
5138
// Detect both ATXHeading and SetextHeading

CoreEditor/src/styling/markdown.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { markdownMathExtension as mathExtension } from '../@vendor/joplin/markdo
55
import { tags } from './builder';
66
import { listIndentStyle } from './nodes/indent';
77
import { inlineCodeStyle, codeBlockStyle, previewMermaid, previewMath } from './nodes/code';
8-
import { linkStyle } from './nodes/link';
8+
import { linkStyles } from './nodes/link';
99
import { previewTable, tableStyle } from './nodes/table';
1010
import { frontMatterStyle } from './nodes/frontMatter';
1111
import { taskMarkerStyle } from './nodes/task';
@@ -72,7 +72,7 @@ export const markdownExtendedData = {
7272
export const renderExtensions = [
7373
inlineCodeStyle,
7474
codeBlockStyle,
75-
linkStyle,
75+
linkStyles,
7676
listIndentStyle,
7777
tableStyle,
7878
frontMatterStyle,

CoreEditor/src/styling/nodes/link.ts

Lines changed: 119 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import { Decoration, MatchDecorator } from '@codemirror/view';
1+
import { Decoration, EditorView, MatchDecorator } from '@codemirror/view';
2+
import { EditorSelection, Extension } from '@codemirror/state';
3+
import { SyntaxNodeRef } from '@lezer/common';
24
import { createDecoPlugin } from '../helper';
5+
import { createDecos } from '../matchers/lezer';
36
import { isReleaseMode } from '../../common/utils';
47
import { isMetaKeyDown } from '../../modules/events';
8+
import { getNodesNamed } from '../../modules/lezer';
59

6-
// Fragile approach, but we only use it for link clicking, it should be fine
7-
const regexp = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\/?([a-zA-Z0-9-]+\.)?[-a-zA-Z0-9@:%._+~#=]+(\.[a-z]+)?\b([-a-zA-Z0-9@:%._+~#=?&//]*)|(\[.*?\]\()(.+?)[\s)]/g;
810
const className = 'cm-md-link';
11+
const regexp = {
12+
standard: /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\/?([a-zA-Z0-9-]+\.)?[-a-zA-Z0-9@:%._+~#=]+(\.[a-z]+)?\b([-a-zA-Z0-9@:%._+~#=?&//]*)|(\[.*?\]\()(.+?)[\s)]/g,
13+
footnote: /^\[\^[^\]]+\]$/,
14+
reference: /^\[[^\]]+\] ?\[([^\]]+)\]$/,
15+
};
916

1017
declare global {
1118
interface Window {
@@ -14,22 +21,20 @@ declare global {
1421
}
1522
}
1623

17-
export const linkStyle = createDecoPlugin(() => {
18-
window._startLinkClickable = startClickable;
19-
window._stopLinkClickable = stopClickable;
24+
window._startLinkClickable = startClickable;
25+
window._stopLinkClickable = stopClickable;
2026

27+
/**
28+
* For standard links like `https://github.com` and `[markdown][link]`.
29+
*/
30+
const standardStyle = createDecoPlugin(() => {
2131
const matcher = new MatchDecorator({
22-
regexp,
32+
// Fragile approach, but we only use it for link clicking, it should be fine
33+
regexp: regexp.standard,
2334
boundary: /\S/,
2435
decorate: (add, from, to, match) => {
25-
const deco = Decoration.mark({
26-
class: className,
27-
attributes: {
28-
title: window.config.localizable?.cmdClickToOpenLink ?? '',
29-
onmouseenter: '_startLinkClickable(this)',
30-
onmouseleave: '_stopLinkClickable(this)',
31-
},
32-
});
36+
const spec = createSpec();
37+
const deco = Decoration.mark(spec);
3338

3439
if (match[4]) {
3540
// Markdown links, only decorate the part inside parentheses
@@ -44,6 +49,42 @@ export const linkStyle = createDecoPlugin(() => {
4449
return matcher.createDeco(window.editor);
4550
});
4651

52+
/**
53+
* For `[^footnote]` and `[reference][link]`.
54+
*/
55+
const referenceStyle = createDecoPlugin(() => {
56+
return createDecos('Link', ({ from, to }) => {
57+
const content = window.editor.state.sliceDoc(from, to);
58+
const newDeco = (type: 'Link' | 'LinkLabel', label: string) => Decoration.mark(createSpec({
59+
'data-link-type': type,
60+
'data-link-from': `${from}`,
61+
'data-link-to': `${to}`,
62+
'data-link-label': label,
63+
})).range(from, to);
64+
65+
// [^footnote]
66+
const footnote = content.match(regexp.footnote);
67+
if (footnote !== null) {
68+
// Looking for the entire link
69+
return newDeco('Link', footnote[0]);
70+
}
71+
72+
// [reference][link]
73+
const reference = content.match(regexp.reference);
74+
if (reference !== null) {
75+
// Looking for the label only
76+
return newDeco('LinkLabel', `[${reference[1]}]`);
77+
}
78+
79+
return null;
80+
});
81+
});
82+
83+
export const linkStyles: Extension = [
84+
standardStyle,
85+
referenceStyle,
86+
];
87+
4788
export function startClickable(inputElement?: HTMLElement) {
4889
const linkElement = inputElement ?? storage.focusedElement;
4990
storage.focusedElement = linkElement;
@@ -71,25 +112,32 @@ export function stopClickable(inputElement?: HTMLElement) {
71112
return;
72113
}
73114

74-
linkElement.title = window.config.localizable?.cmdClickToOpenLink ?? '';
115+
linkElement.title = window.config.localizable?.cmdClickToFollow ?? '';
75116
linkElement.style.cursor = '';
76117
linkElement.style.textDecoration = '';
77118
linkElement.style.textDecorationColor = '';
78119
}
79120

80121
export function handleMouseDown(event: MouseEvent) {
81-
if (extractLink(event.target) !== undefined) {
122+
if (extractLink(event.target).link !== undefined) {
82123
event.stopPropagation();
83124
event.preventDefault();
84125
}
85126
}
86127

87128
export function handleMouseUp(event: MouseEvent) {
88-
const link = extractLink(event.target);
129+
const { link, element } = extractLink(event.target);
89130
if (link === undefined) {
90131
return;
91132
}
92133

134+
// [^footnote] or [reference][link]
135+
const type = element.getAttribute('data-link-type');
136+
if (type !== null) {
137+
return followReference(element, type);
138+
}
139+
140+
// [standard][link] or <standard-link>
93141
if (isReleaseMode) {
94142
window.nativeModules.core.notifyLinkClicked({ link });
95143
} else {
@@ -104,23 +152,72 @@ function extractLink(target: EventTarget | null) {
104152

105153
// The element doesn't belong to a Markdown link
106154
if (element === null || element === undefined) {
107-
return undefined;
155+
return {};
108156
}
109157

110158
// The link is clickable when it has an underline,
111159
// use includes because Chrome merges textDecorationColor into textDecoration.
112160
if (!element.style.textDecoration.includes('underline')) {
113-
return undefined;
161+
return {};
114162
}
115163

116164
// It's OK to have a trailing period in a valid url,
117165
// but generally it's the end of a sentence and we want to remove the period.
118166
const link = element.innerText;
119167
if (link.endsWith('.') === true && link.endsWith('..') !== true) {
120-
return link.slice(0, -1);
168+
return { element, link: link.slice(0, -1) };
121169
}
122170

123-
return link;
171+
return { element, link };
172+
}
173+
174+
function createSpec(attributes?: { [key: string]: string }) {
175+
return {
176+
class: className,
177+
attributes: {
178+
title: window.config.localizable?.cmdClickToFollow ?? '',
179+
onmouseenter: '_startLinkClickable(this)',
180+
onmouseleave: '_stopLinkClickable(this)',
181+
...attributes,
182+
},
183+
};
184+
};
185+
186+
function followReference(element: HTMLElement, type: string) {
187+
const state = window.editor.state;
188+
const from = parseInt(element.getAttribute('data-link-from') ?? '0');
189+
const to = parseInt(element.getAttribute('data-link-to') ?? '0');
190+
const label = element.getAttribute('data-link-label') ?? '';
191+
const isDefinition = (pos: number) => state.sliceDoc(pos, pos + 1) === ':';
192+
193+
return scrollIntoTarget(getNodesNamed(state, type).find(node => {
194+
if (node.to >= from && node.from <= to) {
195+
return false;
196+
}
197+
198+
if (state.sliceDoc(node.from, node.to) !== label) {
199+
return false;
200+
}
201+
202+
// For [^footnote], if definition is cmd-clicked, goto the first reference
203+
if (type === 'Link') {
204+
return isDefinition(to) ? true : isDefinition(node.to);
205+
}
206+
207+
// For [reference][link], always goto the definition
208+
return isDefinition(node.to);
209+
}));
210+
}
211+
212+
function scrollIntoTarget(target?: SyntaxNodeRef) {
213+
if (target === undefined) {
214+
return window.nativeModules.core.notifyLightWarning();
215+
}
216+
217+
window.editor.dispatch({
218+
selection: EditorSelection.range(target.from, target.to),
219+
effects: EditorView.scrollIntoView(target.from, { y: 'center' }),
220+
});
124221
}
125222

126223
const storage: {

CoreEditor/test/lezer.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from '@jest/globals';
22
import { EditorView } from '@codemirror/view';
33
import { syntaxTree } from '@codemirror/language';
4-
import { extractComments } from '../src/modules/lezer';
4+
import { getNodesNamed, extractComments } from '../src/modules/lezer';
55
import * as editor from './utils/editor';
66

77
describe('Lezer parser', () => {
@@ -78,6 +78,29 @@ describe('Lezer parser', () => {
7878
expect(types).toContain('HeaderMark');
7979
});
8080

81+
test('test footnote', () => {
82+
editor.setUp('[^footnote]\n\n[^footnote]:');
83+
84+
const types = parseTypes(window.editor);
85+
expect(types).toContain('Link');
86+
expect(types).toContain('LinkMark');
87+
});
88+
89+
test('test reference style link', () => {
90+
editor.setUp('[reference][link]\n\n[link]:');
91+
92+
const types = parseTypes(window.editor);
93+
expect(types).toContain('Link');
94+
expect(types).toContain('LinkLabel');
95+
});
96+
97+
test('test getNodesNamed', () => {
98+
editor.setUp('[^footnote]\n\n[reference][link]\n\n[standard](link)');
99+
100+
const nodes = getNodesNamed(window.editor.state, 'Link');
101+
expect(nodes.length).toBe(3);
102+
});
103+
81104
test('test extracting comments', () => {
82105
expect(extractComments('Hello')).toStrictEqual({
83106
trimmedText: 'Hello',

MarkEditCore/Sources/EditorLocalizable.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public struct EditorLocalizable: Encodable {
1818
let foldLine: String
1919
let unfoldLine: String
2020
let previewButtonTitle: String
21-
let cmdClickToOpenLink: String
21+
let cmdClickToFollow: String
2222
let cmdClickToToggleTodo: String
2323

2424
public init(
@@ -30,7 +30,7 @@ public struct EditorLocalizable: Encodable {
3030
foldLine: String,
3131
unfoldLine: String,
3232
previewButtonTitle: String,
33-
cmdClickToOpenLink: String,
33+
cmdClickToFollow: String,
3434
cmdClickToToggleTodo: String
3535
) {
3636
self.controlCharacter = controlCharacter
@@ -41,7 +41,7 @@ public struct EditorLocalizable: Encodable {
4141
self.foldLine = foldLine
4242
self.unfoldLine = unfoldLine
4343
self.previewButtonTitle = previewButtonTitle
44-
self.cmdClickToOpenLink = cmdClickToOpenLink
44+
self.cmdClickToFollow = cmdClickToFollow
4545
self.cmdClickToToggleTodo = cmdClickToToggleTodo
4646
}
4747
}

0 commit comments

Comments
 (0)