overleaf/services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts

145 lines
4.2 KiB
TypeScript
Raw Normal View History

import {
bracketMatching as bracketMatchingExtension,
matchBrackets,
type MatchResult,
} from '@codemirror/language'
import { Decoration, EditorView } from '@codemirror/view'
import {
EditorSelection,
Extension,
SelectionRange,
type Range,
} from '@codemirror/state'
const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' })
const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' })
const FORWARDS = 1
const BACKWARDS = -1
type Direction = 1 | -1
/**
* A built-in extension which decorates matching pairs of brackets when focused,
* configured with a custom render function that combines adjacent pairs of matching markers
* into a single decoration so theres no border between them.
*/
export const bracketMatching = () => {
return bracketMatchingExtension({
renderMatch: match => {
const decorations: Range<Decoration>[] = []
if (matchedAdjacent(match)) {
// combine an adjacent pair of matching markers into a single decoration
decorations.push(
matchingMark.range(
Math.min(match.start.from, match.end.from),
Math.max(match.start.to, match.end.to)
)
)
} else {
// default match rendering (defaultRenderMatch in @codemirror/matchbrackets)
const mark = match.matched ? matchingMark : nonmatchingMark
decorations.push(mark.range(match.start.from, match.start.to))
if (match.end) {
decorations.push(mark.range(match.end.from, match.end.to))
}
}
return decorations
},
})
}
interface AdjacentMatchResult extends MatchResult {
end: {
from: number
to: number
}
}
const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult =>
Boolean(
match.matched &&
match.end &&
(match.start.to === match.end.from || match.end.to === match.start.from)
)
/**
* A custom extension which handles double-click events on a matched bracket
* and extends the selection to cover the contents of the bracket pair.
*/
export const bracketSelection = (): Extension[] => [
EditorView.domEventHandlers({
dblclick: (evt, view) => {
const pos = view.posAtCoords({
x: evt.pageX,
y: evt.pageY,
})
if (!pos) return false
const search = (direction: Direction, position: number) => {
const match = matchBrackets(view.state, position, direction, {
// Only look at data in the syntax tree, don't scan the text
maxScanDistance: 0,
})
if (match?.matched && match.end) {
return EditorSelection.range(
Math.min(match.start.from, match.end.from),
Math.max(match.end.to, match.start.to)
)
}
return false
}
const dispatchSelection = (range: SelectionRange) => {
view.dispatch({
selection: range,
})
return true
}
// 1. Look forwards, from the character *behind* the cursor
const forwardsExcludingBrackets = search(FORWARDS, pos - 1)
if (forwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
forwardsExcludingBrackets.from + 1,
forwardsExcludingBrackets.to - 1
)
)
}
// 2. Look forwards, from the character *in front of* the cursor
const forwardsIncludingBrackets = search(FORWARDS, pos)
if (forwardsIncludingBrackets) {
return dispatchSelection(forwardsIncludingBrackets)
}
// 3. Look backwards, from the character *behind* the cursor
const backwardsIncludingBrackets = search(BACKWARDS, pos)
if (backwardsIncludingBrackets) {
return dispatchSelection(backwardsIncludingBrackets)
}
// 4. Look backwards, from the character *in front of* the cursor
const backwardsExcludingBrackets = search(BACKWARDS, pos + 1)
if (backwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
backwardsExcludingBrackets.from + 1,
backwardsExcludingBrackets.to - 1
)
)
}
return false
},
}),
matchingBracketTheme,
]
const matchingBracketTheme = EditorView.baseTheme({
'.cm-matchingBracket': {
pointerEvents: 'none',
},
})