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' ;
24import { createDecoPlugin } from '../helper' ;
5+ import { createDecos } from '../matchers/lezer' ;
36import { isReleaseMode } from '../../common/utils' ;
47import { 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 - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : \/ \/ \/ ? ( [ a - z A - Z 0 - 9 - ] + \. ) ? [ - a - z A - Z 0 - 9 @ : % . _ + ~ # = ] + ( \. [ a - z ] + ) ? \b ( [ - a - z A - Z 0 - 9 @ : % . _ + ~ # = ? & / / ] * ) | ( \[ .* ?\] \( ) ( .+ ?) [ \s ) ] / g;
810const className = 'cm-md-link' ;
11+ const regexp = {
12+ standard : / [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : \/ \/ \/ ? ( [ a - z A - Z 0 - 9 - ] + \. ) ? [ - a - z A - Z 0 - 9 @ : % . _ + ~ # = ] + ( \. [ a - z ] + ) ? \b ( [ - a - z A - Z 0 - 9 @ : % . _ + ~ # = ? & / / ] * ) | ( \[ .* ?\] \( ) ( .+ ?) [ \s ) ] / g,
13+ footnote : / ^ \[ \^ [ ^ \] ] + \] $ / ,
14+ reference : / ^ \[ [ ^ \] ] + \] ? \[ ( [ ^ \] ] + ) \] $ / ,
15+ } ;
916
1017declare 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+
4788export 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
80121export 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
87128export 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
126223const storage : {
0 commit comments