11import { syntaxTree } from "@codemirror/language" ;
2- import { Decoration , ViewPlugin , WidgetType } from "@codemirror/view" ;
2+ import { Decoration , ViewPlugin , EditorView } from "@codemirror/view" ;
33
44const isWindows = navigator . userAgent . includes ( "Windows" ) ;
55
6- class LinkWidget extends WidgetType {
7- constructor ( url ) {
8- super ( ) ;
9- this . url = url ;
10- }
11-
12- toDOM ( ) {
13- const link = document . createElement ( "a" ) ;
14- link . href = this . url ;
15- link . textContent = this . url ;
16- link . target = "_blank" ;
17- link . rel = "noopener noreferrer" ;
18- link . className = "cm-comment-link" ;
19- link . title = `Open in a new tab (${ isWindows ? "Ctrl" : "Cmd" } + Click)` ;
20-
21- // Prevent default click behavior - only open on Cmd+Click
22- const preventDefault = ( e ) => ! e . metaKey && ! e . ctrlKey && e . preventDefault ( ) ;
23- link . addEventListener ( "click" , preventDefault ) ;
24-
25- // Change cursor to pointer when Ctrl/Cmd is held
26- const add = ( e ) => ( e . metaKey || e . ctrlKey ) && link . classList . add ( "cmd-clickable" ) ;
27- const remove = ( e ) => ! e . metaKey && ! e . ctrlKey && link . classList . remove ( "cmd-clickable" ) ;
28- link . addEventListener ( "mouseenter" , add ) ;
29- link . addEventListener ( "mousemove" , add ) ;
30- link . addEventListener ( "mouseleave" , remove ) ;
31- return link ;
32- }
33-
34- ignoreEvent ( event ) {
35- // Ignore click events when Cmd/Ctrl is held (for opening links)
36- if ( event . type === "mousedown" || event . type === "click" ) {
37- return event . metaKey || event . ctrlKey ;
38- }
39- return false ;
40- }
6+ // Create a decoration that marks links but keeps text selectable.
7+ function createLinkDecoration ( url ) {
8+ return Decoration . mark ( {
9+ class : "cm-comment-link" ,
10+ attributes : {
11+ "data-url" : url ,
12+ title : `Open in a new tab (${ isWindows ? "Ctrl" : "Cmd" } + Click)` ,
13+ } ,
14+ } ) ;
4115}
4216
4317export const commentLink = ViewPlugin . fromClass (
@@ -53,7 +27,7 @@ export const commentLink = ViewPlugin.fromClass(
5327 }
5428
5529 buildDeco ( view ) {
56- let widgets = [ ] ;
30+ let decorations = [ ] ;
5731 let tree = syntaxTree ( view . state ) ;
5832
5933 // Only iterate tree nodes that intersect with the viewport.
@@ -64,31 +38,55 @@ export const commentLink = ViewPlugin.fromClass(
6438 enter : ( { type, from, to} ) => {
6539 if ( type . name === "BlockComment" || type . name === "LineComment" ) {
6640 const text = view . state . doc . sliceString ( from , to ) ;
67- let httpRegex = / h t t p s ? : \/ \/ [ ^ \s \) ] + / g;
41+ // Match URLs, allowing common URL characters, then trim trailing punctuation.
42+ let httpRegex = / h t t p s ? : \/ \/ [ ^ \s \) \] \} ] + / g;
6843 let m ;
6944 while ( ( m = httpRegex . exec ( text ) ) ) {
45+ let url = m [ 0 ] ;
46+ // Trim trailing punctuation that shouldn't be part of URL.
47+ // These are common sentence/clause-ending punctuation.
48+ url = url . replace ( / [ . , ; : ! ? ) \] } > ] + $ / , "" ) ;
49+ if ( ! url || url . length < 11 ) continue ; // Minimum valid URL length.
7050 const tagStart = from + m . index ;
71- const tagEnd = tagStart + m [ 0 ] . length ;
51+ const tagEnd = tagStart + url . length ;
7252 if ( tagEnd >= from && tagStart <= to ) {
73- const url = m [ 0 ] ;
74- const widget = new LinkWidget ( url ) ;
75- widgets . push (
76- Decoration . replace ( {
77- widget,
78- inclusive : true ,
79- } ) . range ( tagStart , tagEnd ) ,
80- ) ;
53+ decorations . push ( createLinkDecoration ( url ) . range ( tagStart , tagEnd ) ) ;
8154 }
8255 }
8356 }
8457 } ,
8558 } ) ;
8659 }
8760
88- return Decoration . set ( widgets , true ) ;
61+ return Decoration . set ( decorations , true ) ;
8962 }
9063 } ,
9164 {
9265 decorations : ( v ) => v . decorations ,
9366 } ,
9467) ;
68+
69+ // Handle clicks and cursor updates on comment links.
70+ export const commentLinkClickHandler = EditorView . domEventHandlers ( {
71+ mousedown ( event ) {
72+ const target = event . target . closest ?. ( ".cm-comment-link" ) ;
73+ if ( ! target ) return false ;
74+ const url = target . getAttribute ( "data-url" ) ;
75+ if ( ! url ) return false ;
76+ if ( event . metaKey || event . ctrlKey ) {
77+ event . preventDefault ( ) ;
78+ window . open ( url , "_blank" , "noopener,noreferrer" ) ;
79+ return true ;
80+ }
81+ return false ;
82+ } ,
83+ mousemove ( event ) {
84+ const element = event . target ;
85+ const isCmdHeld = event . metaKey || event . ctrlKey ;
86+ const target = element ?. closest ?. ( ".cm-comment-link" ) ;
87+ if ( ! target ) return false ;
88+ if ( isCmdHeld ) target . classList . add ( "cmd-clickable" ) ;
89+ else target . classList . remove ( "cmd-clickable" ) ;
90+ return false ;
91+ } ,
92+ } ) ;
0 commit comments