Skip to content

Commit c39d0a9

Browse files
committed
Add bracketMatching
1 parent ab52adb commit c39d0a9

File tree

2 files changed

+153
-0
lines changed

2 files changed

+153
-0
lines changed

codejar.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type Options = {
1414
open: string;
1515
close: string;
1616
}
17+
bracketMatching: boolean
18+
bracketMatchingClass: string
1719
}
1820

1921
type 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,62 @@ 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: 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+
473620
function visit(editor: HTMLElement, visitor: (el: Node) => 'stop' | undefined) {
474621
const queue: Node[] = []
475622
if (editor.firstChild) queue.push(editor.firstChild)

demo.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
padding: 10px;
3737
tab-size: 4;
3838
}
39+
40+
.matching {
41+
outline: 1px solid currentColor;
42+
}
3943
</style>
4044
</head>
4145
<body>
@@ -52,6 +56,8 @@
5256

5357
const jar = CodeJar(editor, highlight, {
5458
tab: ' ',
59+
bracketMatching: 'matching',
60+
bracketMatching: true
5561
})
5662

5763
jar.updateCode(localStorage.getItem('code'))

0 commit comments

Comments
 (0)