close-prefixed-brackets.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * This file is adapted from CodeMirror 6, licensed under the MIT license:
  3. * https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
  4. */
  5. import { EditorView } from '@codemirror/view'
  6. import {
  7. codePointAt,
  8. codePointSize,
  9. EditorSelection,
  10. Extension,
  11. SelectionRange,
  12. Text,
  13. TransactionSpec,
  14. } from '@codemirror/state'
  15. import { nextChar, prevChar } from '../languages/latex/completions/apply'
  16. import { completionStatus } from '@codemirror/autocomplete'
  17. import { ancestorNodeOfType } from '../utils/tree-query'
  18. import browser from './browser'
  19. const dispatchInput = (view: EditorView, spec: TransactionSpec) => {
  20. // This is consistent with CM6's closebrackets extension and allows other
  21. // extensions that check for user input to be triggered
  22. view.dispatch(spec, {
  23. scrollIntoView: true,
  24. userEvent: 'input.type',
  25. })
  26. return true
  27. }
  28. const insertInput = (view: EditorView, insert: string) => {
  29. const spec = view.state.changeByRange(range => {
  30. return {
  31. changes: [[{ from: range.from, insert }]],
  32. range: EditorSelection.range(range.from + 1, range.to + 1),
  33. }
  34. })
  35. return dispatchInput(view, spec)
  36. }
  37. const insertBracket = (view: EditorView, open: string, close: string) => {
  38. const spec = view.state.changeByRange(range => {
  39. if (range.empty) {
  40. return {
  41. changes: [{ from: range.head, insert: open + close }],
  42. range: EditorSelection.cursor(range.head + open.length),
  43. }
  44. } else {
  45. return {
  46. changes: [
  47. { from: range.from, insert: open },
  48. { from: range.to, insert: close },
  49. ],
  50. range: EditorSelection.range(
  51. range.anchor + open.length,
  52. range.head + open.length
  53. ),
  54. }
  55. }
  56. })
  57. return dispatchInput(view, spec)
  58. }
  59. export const closePrefixedBrackets = (): Extension => {
  60. return EditorView.inputHandler.of((view, from, to, insert) => {
  61. if (
  62. (browser.android ? view.composing : view.compositionStarted) ||
  63. view.state.readOnly
  64. ) {
  65. return false
  66. }
  67. // avoid auto-closing curly braces when autocomplete is open
  68. if (insert === '{' && completionStatus(view.state)) {
  69. return insertInput(view, insert)
  70. }
  71. const { doc, selection } = view.state
  72. const sel = selection.main
  73. if (
  74. from !== sel.from ||
  75. to !== sel.to ||
  76. insert.length > 2 ||
  77. (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1)
  78. ) {
  79. return false
  80. }
  81. const [config] = view.state.languageDataAt<{
  82. brackets?: Record<string, string | false>
  83. }>('closePrefixedBrackets', sel.head)
  84. // no config for this language, don't handle
  85. if (!config?.brackets) {
  86. return false
  87. }
  88. const prevCharacter = prevChar(view.state.doc, sel.from)
  89. const input = `${prevCharacter}${insert}`
  90. const close = config.brackets[input] ?? config.brackets[insert]
  91. // not specified, don't handle
  92. if (close === undefined) {
  93. return false
  94. }
  95. // prevent auto-close, just insert the character
  96. if (close === false) {
  97. return insertInput(view, insert)
  98. }
  99. const nextCharacter = nextChar(doc, sel.from)
  100. if (insert === '$') {
  101. // avoid duplicating a math-closing dollar sign
  102. if (moveOverClosingMathDollar(view, sel)) {
  103. return true
  104. }
  105. // avoid creating an odd number of dollar signs
  106. const count = countSurroundingCharacters(doc, sel.from, insert)
  107. if (count % 2 !== 0) {
  108. return insertInput(view, insert)
  109. }
  110. }
  111. // This is the default set of "before" characters from the closeBrackets extension,
  112. // plus $ (so $$ works as expected)
  113. if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) {
  114. // auto-close
  115. return insertBracket(view, insert, close)
  116. }
  117. return false
  118. })
  119. }
  120. const moveOverClosingMathDollar = (
  121. view: EditorView,
  122. sel: SelectionRange
  123. ): boolean => {
  124. if (!sel.empty) {
  125. return false
  126. }
  127. // inside dollar math
  128. const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath')
  129. if (!outerNode) {
  130. return false
  131. }
  132. // not display math
  133. const innerNode = outerNode.getChild('InlineMath')
  134. if (!innerNode) {
  135. return false
  136. }
  137. // the cursor is at the end of the InlineMath node
  138. if (sel.from !== innerNode.to) {
  139. return false
  140. }
  141. // there's already some math content
  142. const content = view.state.doc.sliceString(innerNode.from, innerNode.to)
  143. if (content.length === 0) {
  144. return false
  145. }
  146. // move the cursor outside the DollarMath node
  147. view.dispatch({
  148. selection: EditorSelection.cursor(outerNode.to),
  149. })
  150. return true
  151. }
  152. const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => {
  153. let count = 0
  154. // count backwards
  155. let to = pos
  156. do {
  157. const char = doc.sliceString(to - 1, to)
  158. if (char !== insert) {
  159. break
  160. }
  161. count++
  162. to--
  163. } while (to > 1)
  164. // count forwards
  165. let from = pos
  166. do {
  167. const char = doc.sliceString(from, from + 1)
  168. if (char !== insert) {
  169. break
  170. }
  171. count++
  172. from++
  173. } while (from < doc.length)
  174. return count
  175. }