@@ -14,6 +14,8 @@ type Options = {
14
14
open : string ;
15
15
close : string ;
16
16
}
17
+ bracketMatching : boolean
18
+ bracketMatchingClass : string
17
19
}
18
20
19
21
type HistoryRecord = {
@@ -44,6 +46,8 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
44
46
open : `([{'"` ,
45
47
close : `)]}'"`
46
48
} ,
49
+ bracketMatching : false ,
50
+ bracketMatchingClass : 'matching' ,
47
51
...opt ,
48
52
}
49
53
@@ -57,6 +61,11 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
57
61
let onUpdate : ( code : string ) => void | undefined = ( ) => void 0
58
62
let prev : string // code content prior keydown event
59
63
64
+ // Variables for bracket matching
65
+ let bracketMap : Array < { pos : number , bracket : string , node : Element } > = [ ]
66
+ let matchingMap : Record < number , number > = { }
67
+ let highlightedNodes : Element [ ] = [ ] // Nodes currently highlighted
68
+
60
69
editor . setAttribute ( 'contenteditable' , 'plaintext-only' )
61
70
editor . setAttribute ( 'spellcheck' , options . spellcheck ? 'true' : 'false' )
62
71
editor . style . outline = 'none'
@@ -66,6 +75,10 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
66
75
67
76
const doHighlight = ( editor : HTMLElement , pos ?: Position ) => {
68
77
highlight ( editor , pos )
78
+ if ( options . bracketMatching ) {
79
+ bracketMap = getBracketMap ( )
80
+ matchingMap = buildMatchingMap ( bracketMap )
81
+ }
69
82
}
70
83
71
84
let isLegacy = false // true if plaintext-only is not supported
@@ -78,6 +91,78 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
78
91
restore ( pos )
79
92
} , 30 )
80
93
94
+ // Debounced function for bracket matching
95
+ const debounceBracketMatching = debounce ( ( ) => {
96
+ if ( ! options . bracketMatching ) return
97
+
98
+ // Clear previous highlights
99
+ highlightedNodes . forEach ( node => node . classList . remove ( options . bracketMatchingClass ) )
100
+ highlightedNodes = [ ]
101
+
102
+ // Get current selection and ensure it's a single cursor position.
103
+ const s = getSelection ( )
104
+ if ( s . rangeCount === 0 ) return
105
+ const range = s . getRangeAt ( 0 )
106
+ if ( ! range . collapsed ) return // Only highlight when there's a single caret
107
+
108
+ const pos = save ( )
109
+ const cursorPos = pos . start
110
+
111
+ // Determine which bracket pair to highlight based on VS Code–like behavior.
112
+ const pairPositions = findBracketPair ( cursorPos )
113
+
114
+ if ( pairPositions . length ) {
115
+ for ( const position of pairPositions ) {
116
+ const bracket = bracketMap . find ( b => b . pos === position )
117
+ if ( bracket ) {
118
+ bracket . node . classList . add ( options . bracketMatchingClass )
119
+ highlightedNodes . push ( bracket . node )
120
+ }
121
+ }
122
+ }
123
+ } , 30 )
124
+
125
+ function findBracketPair ( cursorPos : number ) : number [ ] {
126
+ // Check for adjacent brackets first.
127
+ const before = bracketMap . find ( b => b . pos === cursorPos - 1 )
128
+ const after = bracketMap . find ( b => b . pos === cursorPos )
129
+
130
+ // If a closing bracket is immediately after the cursor, prefer that.
131
+ if ( after && '}])' . includes ( after . bracket ) ) {
132
+ const match = matchingMap [ after . pos ]
133
+ if ( match !== undefined ) {
134
+ return [ match , after . pos ]
135
+ }
136
+ }
137
+ // Otherwise, if an opening bracket is immediately before the cursor, use that.
138
+ if ( before && '{[(' . includes ( before . bracket ) ) {
139
+ const match = matchingMap [ before . pos ]
140
+ if ( match !== undefined ) {
141
+ return [ before . pos , match ]
142
+ }
143
+ }
144
+
145
+ // If no adjacent bracket is found, search for an enclosing pair.
146
+ let candidate : { open : number , close : number } | null = null
147
+ for ( const b of bracketMap ) {
148
+ if ( '{[(' . includes ( b . bracket ) ) {
149
+ const openPos = b . pos
150
+ const closePos = matchingMap [ openPos ]
151
+ // Check if the cursor lies strictly between the opening and closing bracket.
152
+ if ( openPos < cursorPos && closePos > cursorPos ) {
153
+ // Choose the innermost (closest) enclosing pair.
154
+ if ( ! candidate || openPos > candidate . open ) {
155
+ candidate = { open : openPos , close : closePos }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ if ( candidate ) {
161
+ return [ candidate . open , candidate . close ]
162
+ }
163
+ return [ ]
164
+ }
165
+
81
166
let recording = false
82
167
const shouldRecord = ( event : KeyboardEvent ) : boolean => {
83
168
return ! isUndo ( event ) && ! isRedo ( event )
@@ -147,6 +232,12 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
147
232
onUpdate ( toString ( ) )
148
233
} )
149
234
235
+ // Add bracket matching listeners if enabled
236
+ if ( options . bracketMatching ) {
237
+ on ( 'keyup' , debounceBracketMatching )
238
+ on ( 'mouseup' , debounceBracketMatching )
239
+ }
240
+
150
241
function save ( ) : Position {
151
242
const s = getSelection ( )
152
243
const pos : Position = { start : 0 , end : 0 , dir : undefined }
@@ -470,6 +561,62 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
470
561
preventDefault ( event )
471
562
}
472
563
564
+ function getBracketMap ( ) : Array < { pos : number , bracket : string , node : Element } > {
565
+ const brackets : Array < { pos : number , bracket : string , node : Element } > = [ ]
566
+ let pos = 0
567
+
568
+ function traverse ( node : Node ) {
569
+ if ( node . nodeType === Node . TEXT_NODE ) {
570
+ pos += node . nodeValue ?. length || 0
571
+ } else if ( node . nodeType === Node . ELEMENT_NODE ) {
572
+ // If it's a SPAN and qualifies as a bracket token.
573
+ if ( node . nodeName === 'SPAN' && node . childNodes . length === 1 && node . firstChild ?. nodeType === Node . TEXT_NODE ) {
574
+ const text = node . textContent
575
+ if ( text ?. length === 1 && '{[()]}' . includes ( text ) ) {
576
+ brackets . push ( { pos, bracket : text , node : node as Element } )
577
+ pos += 1
578
+ return // Skip traversing children, we've handled the token.
579
+ }
580
+ }
581
+ // For non-bracket elements or multi-character spans, traverse children.
582
+ for ( let i = 0 ; i < node . childNodes . length ; i ++ ) {
583
+ traverse ( node . childNodes [ i ] )
584
+ }
585
+ }
586
+ }
587
+
588
+ traverse ( editor )
589
+ return brackets
590
+ }
591
+
592
+ function buildMatchingMap ( brackets : Array < { pos : number , bracket : string , node : Element } > ) : Record < number , number > {
593
+ const matching : Record < number , number > = { }
594
+ const stack : Array < { pos : number , bracket : string , node : Element } > = [ ]
595
+ const bracketPairs : Record < string , string > = {
596
+ '(' : ')' ,
597
+ '[' : ']' ,
598
+ '{' : '}'
599
+ }
600
+
601
+ for ( const bracket of brackets ) {
602
+ if ( bracket . bracket in bracketPairs ) {
603
+ // Opening bracket: push it onto the stack.
604
+ stack . push ( bracket )
605
+ } else {
606
+ // Closing bracket: iterate backward in the stack to find a matching opening bracket.
607
+ for ( let i = stack . length - 1 ; i >= 0 ; i -- ) {
608
+ if ( bracketPairs [ stack [ i ] . bracket ] === bracket . bracket ) {
609
+ const openBracket = stack . splice ( i , 1 ) [ 0 ]
610
+ matching [ openBracket . pos ] = bracket . pos
611
+ matching [ bracket . pos ] = openBracket . pos
612
+ break
613
+ }
614
+ }
615
+ }
616
+ }
617
+ return matching
618
+ }
619
+
473
620
function visit ( editor : HTMLElement , visitor : ( el : Node ) => 'stop' | undefined ) {
474
621
const queue : Node [ ] = [ ]
475
622
if ( editor . firstChild ) queue . push ( editor . firstChild )
0 commit comments