bracket-matching.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import {
  2. bracketMatching as bracketMatchingExtension,
  3. matchBrackets,
  4. type MatchResult,
  5. } from '@codemirror/language'
  6. import { Decoration, EditorView } from '@codemirror/view'
  7. import {
  8. EditorSelection,
  9. Extension,
  10. SelectionRange,
  11. type Range,
  12. } from '@codemirror/state'
  13. import browser from '@/features/source-editor/extensions/browser'
  14. const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' })
  15. const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' })
  16. const FORWARDS = 1
  17. const BACKWARDS = -1
  18. type Direction = 1 | -1
  19. /**
  20. * A built-in extension which decorates matching pairs of brackets when focused,
  21. * configured with a custom render function that combines adjacent pairs of matching markers
  22. * into a single decoration so there’s no border between them.
  23. */
  24. export const bracketMatching = () => {
  25. return bracketMatchingExtension({
  26. renderMatch: match => {
  27. const decorations: Range<Decoration>[] = []
  28. if (matchedAdjacent(match)) {
  29. // combine an adjacent pair of matching markers into a single decoration
  30. decorations.push(
  31. matchingMark.range(
  32. Math.min(match.start.from, match.end.from),
  33. Math.max(match.start.to, match.end.to)
  34. )
  35. )
  36. } else {
  37. // default match rendering (defaultRenderMatch in @codemirror/matchbrackets)
  38. const mark = match.matched ? matchingMark : nonmatchingMark
  39. decorations.push(mark.range(match.start.from, match.start.to))
  40. if (match.end) {
  41. decorations.push(mark.range(match.end.from, match.end.to))
  42. }
  43. }
  44. return decorations
  45. },
  46. })
  47. }
  48. interface AdjacentMatchResult extends MatchResult {
  49. end: {
  50. from: number
  51. to: number
  52. }
  53. }
  54. const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult =>
  55. Boolean(
  56. match.matched &&
  57. match.end &&
  58. (match.start.to === match.end.from || match.end.to === match.start.from)
  59. )
  60. /**
  61. * A custom extension which handles double-click events on a matched bracket
  62. * and extends the selection to cover the contents of the bracket pair.
  63. */
  64. export const bracketSelection = (): Extension => {
  65. return [chooseEventHandler(), matchingBracketTheme]
  66. }
  67. const chooseEventHandler = () => {
  68. // Safari doesn't always fire the second "click" event, so use dblclick instead
  69. if (browser.safari) {
  70. return [
  71. EditorView.domEventHandlers({
  72. dblclick: handleDoubleClick,
  73. }),
  74. ]
  75. }
  76. // Store and use the previous click event, as the "dblclick" event can have the wrong position
  77. let lastClickEvent: MouseEvent | null = null
  78. return EditorView.domEventHandlers({
  79. click(evt, view) {
  80. if (evt.detail === 1) {
  81. lastClickEvent = evt
  82. } else if (evt.detail === 2 && lastClickEvent) {
  83. return handleDoubleClick(lastClickEvent, view)
  84. }
  85. },
  86. })
  87. }
  88. const handleDoubleClick = (evt: MouseEvent, view: EditorView) => {
  89. const pos = view.posAtCoords({
  90. x: evt.pageX,
  91. y: evt.pageY,
  92. })
  93. if (!pos) return false
  94. const search = (direction: Direction, position: number) => {
  95. const match = matchBrackets(view.state, position, direction, {
  96. // Only look at data in the syntax tree, don't scan the text
  97. maxScanDistance: 0,
  98. })
  99. if (match?.matched && match.end) {
  100. return EditorSelection.range(
  101. Math.min(match.start.from, match.end.from),
  102. Math.max(match.end.to, match.start.to)
  103. )
  104. }
  105. return false
  106. }
  107. const dispatchSelection = (range: SelectionRange) => {
  108. view.dispatch({
  109. selection: range,
  110. })
  111. return true
  112. }
  113. // 1. Look forwards, from the character *behind* the cursor
  114. const forwardsExcludingBrackets = search(FORWARDS, pos - 1)
  115. if (forwardsExcludingBrackets) {
  116. return dispatchSelection(
  117. EditorSelection.range(
  118. forwardsExcludingBrackets.from + 1,
  119. forwardsExcludingBrackets.to - 1
  120. )
  121. )
  122. }
  123. // 2. Look forwards, from the character *in front of* the cursor
  124. const forwardsIncludingBrackets = search(FORWARDS, pos)
  125. if (forwardsIncludingBrackets) {
  126. return dispatchSelection(forwardsIncludingBrackets)
  127. }
  128. // 3. Look backwards, from the character *behind* the cursor
  129. const backwardsIncludingBrackets = search(BACKWARDS, pos)
  130. if (backwardsIncludingBrackets) {
  131. return dispatchSelection(backwardsIncludingBrackets)
  132. }
  133. // 4. Look backwards, from the character *in front of* the cursor
  134. const backwardsExcludingBrackets = search(BACKWARDS, pos + 1)
  135. if (backwardsExcludingBrackets) {
  136. return dispatchSelection(
  137. EditorSelection.range(
  138. backwardsExcludingBrackets.from + 1,
  139. backwardsExcludingBrackets.to - 1
  140. )
  141. )
  142. }
  143. return false
  144. }
  145. const matchingBracketTheme = EditorView.baseTheme({
  146. '.cm-matchingBracket': {
  147. pointerEvents: 'none',
  148. },
  149. })