@@ -14,6 +14,8 @@ type Options = {
1414 open : string ;
1515 close : string ;
1616 }
17+ bracketMatching : boolean
18+ bracketMatchingClass : string
1719}
1820
1921type HistoryRecord = {
@@ -44,6 +46,8 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
4446 open : `([{'"` ,
4547 close : `)]}'"`
4648 } ,
49+ bracketMatching : false ,
50+ bracketMatchingClass : 'matching' ,
4751 ...opt ,
4852 }
4953
@@ -57,6 +61,11 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
5761 let onUpdate : ( code : string ) => void | undefined = ( ) => void 0
5862 let prev : string // code content prior keydown event
5963
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+
6069 editor . setAttribute ( 'contenteditable' , 'plaintext-only' )
6170 editor . setAttribute ( 'spellcheck' , options . spellcheck ? 'true' : 'false' )
6271 editor . style . outline = 'none'
@@ -66,6 +75,10 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
6675
6776 const doHighlight = ( editor : HTMLElement , pos ?: Position ) => {
6877 highlight ( editor , pos )
78+ if ( options . bracketMatching ) {
79+ bracketMap = getBracketMap ( )
80+ matchingMap = buildMatchingMap ( bracketMap )
81+ }
6982 }
7083
7184 let isLegacy = false // true if plaintext-only is not supported
@@ -78,6 +91,78 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
7891 restore ( pos )
7992 } , 30 )
8093
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+
81166 let recording = false
82167 const shouldRecord = ( event : KeyboardEvent ) : boolean => {
83168 return ! isUndo ( event ) && ! isRedo ( event )
@@ -147,6 +232,12 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
147232 onUpdate ( toString ( ) )
148233 } )
149234
235+ // Add bracket matching listeners if enabled
236+ if ( options . bracketMatching ) {
237+ on ( 'keyup' , debounceBracketMatching )
238+ on ( 'mouseup' , debounceBracketMatching )
239+ }
240+
150241 function save ( ) : Position {
151242 const s = getSelection ( )
152243 const pos : Position = { start : 0 , end : 0 , dir : undefined }
@@ -470,6 +561,52 @@ export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: P
470561 preventDefault ( event )
471562 }
472563
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 : number [ ] = [ ]
595+ for ( const bracket of brackets ) {
596+ if ( '{[(' . includes ( bracket . bracket ) ) {
597+ stack . push ( bracket . pos )
598+ } else {
599+ const openPos = stack . pop ( )
600+ if ( openPos !== undefined ) {
601+ const closePos = bracket . pos
602+ matching [ openPos ] = closePos
603+ matching [ closePos ] = openPos
604+ }
605+ }
606+ }
607+ return matching
608+ }
609+
473610 function visit ( editor : HTMLElement , visitor : ( el : Node ) => 'stop' | undefined ) {
474611 const queue : Node [ ] = [ ]
475612 if ( editor . firstChild ) queue . push ( editor . firstChild )
0 commit comments