close-brackets.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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, KeyBinding } from '@codemirror/view'
  6. import {
  7. EditorState,
  8. EditorSelection,
  9. Transaction,
  10. Extension,
  11. StateCommand,
  12. StateField,
  13. StateEffect,
  14. MapMode,
  15. CharCategory,
  16. Text,
  17. codePointAt,
  18. fromCodePoint,
  19. codePointSize,
  20. RangeSet,
  21. RangeValue,
  22. SelectionRange,
  23. } from '@codemirror/state'
  24. import { syntaxTree } from '@codemirror/language'
  25. /// Configures bracket closing behavior for a syntax (via
  26. /// [language data](#state.EditorState.languageDataAt)) using the `"closeBrackets"`
  27. /// identifier.
  28. export interface CloseBracketConfig {
  29. /// The opening and closing tokens.
  30. brackets?: string[]
  31. /// Characters in front of which newly opened brackets are
  32. /// automatically closed. Closing always happens in front of
  33. /// whitespace. Defaults to `")]}:;>"`.
  34. before?: string
  35. /// When determining whether a given node may be a string, recognize
  36. /// these prefixes before the opening quote.
  37. stringPrefixes?: string[]
  38. /// An optional callback for overriding the content that's inserted
  39. /// based on surrounding characters
  40. // added by Overleaf
  41. buildInsert?: (
  42. state: EditorState,
  43. range: SelectionRange,
  44. open: string,
  45. close: string
  46. ) => string
  47. }
  48. const defaults: Required<CloseBracketConfig> = {
  49. brackets: ['(', '[', '{', "'", '"'],
  50. before: ')]}:;>',
  51. stringPrefixes: [],
  52. // added by Overleaf
  53. buildInsert: (state, range, open, close) => open + close,
  54. }
  55. const closeBracketEffect = StateEffect.define<number>({
  56. map(value, mapping) {
  57. const mapped = mapping.mapPos(value, -1, MapMode.TrackAfter)
  58. return mapped === null ? undefined : mapped
  59. },
  60. })
  61. const closedBracket = new (class extends RangeValue {})()
  62. closedBracket.startSide = 1
  63. closedBracket.endSide = -1
  64. const bracketState = StateField.define<RangeSet<typeof closedBracket>>({
  65. create() {
  66. return RangeSet.empty
  67. },
  68. update(value, tr) {
  69. if (tr.selection) {
  70. const lineStart = tr.state.doc.lineAt(tr.selection.main.head).from
  71. const prevLineStart = tr.startState.doc.lineAt(
  72. tr.startState.selection.main.head
  73. ).from
  74. if (lineStart !== tr.changes.mapPos(prevLineStart, -1))
  75. value = RangeSet.empty
  76. }
  77. value = value.map(tr.changes)
  78. for (const effect of tr.effects)
  79. if (effect.is(closeBracketEffect))
  80. value = value.update({
  81. add: [closedBracket.range(effect.value, effect.value + 1)],
  82. })
  83. return value
  84. },
  85. })
  86. /// Extension to enable bracket-closing behavior. When a closeable
  87. /// bracket is typed, its closing bracket is immediately inserted
  88. /// after the cursor. When closing a bracket directly in front of a
  89. /// closing bracket inserted by the extension, the cursor moves over
  90. /// that bracket.
  91. export function closeBrackets(): Extension {
  92. return [inputHandler, bracketState]
  93. }
  94. const definedClosing = '()[]{}<>'
  95. function closing(ch: number) {
  96. for (let i = 0; i < definedClosing.length; i += 2)
  97. if (definedClosing.charCodeAt(i) === ch) return definedClosing.charAt(i + 1)
  98. return fromCodePoint(ch < 128 ? ch : ch + 1)
  99. }
  100. function config(state: EditorState, pos: number) {
  101. return (
  102. state.languageDataAt<CloseBracketConfig>('closeBrackets', pos)[0] ||
  103. defaults
  104. )
  105. }
  106. const android =
  107. typeof navigator === 'object' && /Android\b/.test(navigator.userAgent)
  108. const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
  109. if (
  110. (android ? view.composing : view.compositionStarted) ||
  111. view.state.readOnly
  112. )
  113. return false
  114. const sel = view.state.selection.main
  115. if (
  116. insert.length > 2 ||
  117. (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
  118. from !== sel.from ||
  119. to !== sel.to
  120. )
  121. return false
  122. const tr = insertBracket(view.state, insert)
  123. if (!tr) return false
  124. view.dispatch(tr)
  125. return true
  126. })
  127. /// Command that implements deleting a pair of matching brackets when
  128. /// the cursor is between them.
  129. export const deleteBracketPair: StateCommand = ({ state, dispatch }) => {
  130. if (state.readOnly) return false
  131. const conf = config(state, state.selection.main.head)
  132. const tokens = conf.brackets || defaults.brackets
  133. let dont = null
  134. const changes = state.changeByRange(range => {
  135. if (range.empty) {
  136. const before = prevChar(state.doc, range.head)
  137. for (const token of tokens) {
  138. if (
  139. token === before &&
  140. nextChar(state.doc, range.head) === closing(codePointAt(token, 0))
  141. )
  142. return {
  143. changes: {
  144. from: range.head - token.length,
  145. to: range.head + token.length,
  146. },
  147. range: EditorSelection.cursor(range.head - token.length),
  148. }
  149. }
  150. }
  151. return { range: (dont = range) }
  152. })
  153. if (!dont)
  154. dispatch(
  155. state.update(changes, {
  156. scrollIntoView: true,
  157. userEvent: 'delete.backward',
  158. })
  159. )
  160. return !dont
  161. }
  162. /// Close-brackets related key bindings. Binds Backspace to
  163. /// [`deleteBracketPair`](#autocomplete.deleteBracketPair).
  164. export const closeBracketsKeymap: readonly KeyBinding[] = [
  165. { key: 'Backspace', run: deleteBracketPair },
  166. ]
  167. /// Implements the extension's behavior on text insertion. If the
  168. /// given string counts as a bracket in the language around the
  169. /// selection, and replacing the selection with it requires custom
  170. /// behavior (inserting a closing version or skipping past a
  171. /// previously-closed bracket), this function returns a transaction
  172. /// representing that custom behavior. (You only need this if you want
  173. /// to programmatically insert brackets—the
  174. /// [`closeBrackets`](#autocomplete.closeBrackets) extension will
  175. /// take care of running this for user input.)
  176. export function insertBracket(
  177. state: EditorState,
  178. bracket: string
  179. ): Transaction | null {
  180. const conf = config(state, state.selection.main.head)
  181. const tokens = conf.brackets || defaults.brackets
  182. for (const tok of tokens) {
  183. const closed = closing(codePointAt(tok, 0))
  184. if (bracket === tok)
  185. return closed === tok
  186. ? handleSame(
  187. state,
  188. tok,
  189. tokens.indexOf(tok + tok) > -1,
  190. tokens.indexOf(tok + tok + tok) > -1,
  191. conf
  192. )
  193. : handleOpen(state, tok, closed, conf.before || defaults.before, conf)
  194. if (bracket === closed && closedBracketAt(state, state.selection.main.from))
  195. return handleClose(state, tok, closed)
  196. }
  197. return null
  198. }
  199. function closedBracketAt(state: EditorState, pos: number) {
  200. let found = false
  201. state.field(bracketState).between(0, state.doc.length, from => {
  202. if (from === pos) found = true
  203. })
  204. return found
  205. }
  206. function nextChar(doc: Text, pos: number) {
  207. const next = doc.sliceString(pos, pos + 2)
  208. return next.slice(0, codePointSize(codePointAt(next, 0)))
  209. }
  210. function prevChar(doc: Text, pos: number) {
  211. const prev = doc.sliceString(pos - 2, pos)
  212. return codePointSize(codePointAt(prev, 0)) === prev.length
  213. ? prev
  214. : prev.slice(1)
  215. }
  216. function handleOpen(
  217. state: EditorState,
  218. open: string,
  219. close: string,
  220. closeBefore: string,
  221. config: CloseBracketConfig
  222. ) {
  223. // added by Overleaf
  224. const buildInsert = config.buildInsert || defaults.buildInsert
  225. let dont = null
  226. const changes = state.changeByRange(range => {
  227. if (!range.empty)
  228. return {
  229. changes: [
  230. { insert: open, from: range.from },
  231. { insert: close, from: range.to },
  232. ],
  233. effects: closeBracketEffect.of(range.to + open.length),
  234. range: EditorSelection.range(
  235. range.anchor + open.length,
  236. range.head + open.length
  237. ),
  238. }
  239. const next = nextChar(state.doc, range.head)
  240. if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
  241. // added by Overleaf
  242. const insert = buildInsert(state, range, open, close) ?? open + close
  243. return {
  244. changes: { insert, from: range.head },
  245. effects:
  246. // modified by Overleaf
  247. insert === open
  248. ? []
  249. : closeBracketEffect.of(range.head + open.length),
  250. range: EditorSelection.cursor(range.head + open.length),
  251. }
  252. }
  253. return { range: (dont = range) }
  254. })
  255. return dont
  256. ? null
  257. : state.update(changes, {
  258. scrollIntoView: true,
  259. userEvent: 'input.type',
  260. })
  261. }
  262. function handleClose(state: EditorState, _open: string, close: string) {
  263. let dont = null
  264. const changes = state.changeByRange(range => {
  265. if (range.empty && nextChar(state.doc, range.head) === close)
  266. return {
  267. changes: {
  268. from: range.head,
  269. to: range.head + close.length,
  270. insert: close,
  271. },
  272. range: EditorSelection.cursor(range.head + close.length),
  273. }
  274. return (dont = { range })
  275. })
  276. return dont
  277. ? null
  278. : state.update(changes, {
  279. scrollIntoView: true,
  280. userEvent: 'input.type',
  281. })
  282. }
  283. // Handles cases where the open and close token are the same, and
  284. // possibly triple quotes (as in `"""abc"""`-style quoting).
  285. function handleSame(
  286. state: EditorState,
  287. token: string,
  288. // added by Overleaf
  289. allowDouble: boolean,
  290. allowTriple: boolean,
  291. config: CloseBracketConfig
  292. ) {
  293. const stringPrefixes = config.stringPrefixes || defaults.stringPrefixes
  294. // added by Overleaf
  295. const buildInsert = config.buildInsert || defaults.buildInsert
  296. let dont = null
  297. const changes = state.changeByRange(range => {
  298. if (!range.empty)
  299. return {
  300. changes: [
  301. { insert: token, from: range.from },
  302. { insert: token, from: range.to },
  303. ],
  304. effects: closeBracketEffect.of(range.to + token.length),
  305. range: EditorSelection.range(
  306. range.anchor + token.length,
  307. range.head + token.length
  308. ),
  309. }
  310. const pos = range.head
  311. const next = nextChar(state.doc, pos)
  312. let start
  313. if (
  314. allowTriple &&
  315. state.sliceDoc(pos - 2 * token.length, pos) === token + token &&
  316. (start = canStartStringAt(
  317. state,
  318. pos - 2 * token.length,
  319. stringPrefixes
  320. )) > -1 &&
  321. nodeStart(state, start)
  322. ) {
  323. return {
  324. changes: { insert: token + token + token + token, from: pos },
  325. effects: closeBracketEffect.of(pos + token.length),
  326. range: EditorSelection.cursor(pos + token.length),
  327. }
  328. } else if (
  329. // added by Overleaf, for $$
  330. allowDouble &&
  331. state.sliceDoc(pos - token.length, pos) === token &&
  332. (start = canStartStringAt(state, pos - token.length, stringPrefixes)) >
  333. -1 &&
  334. nodeStart(state, start)
  335. ) {
  336. // added by Overleaf
  337. const insert = buildInsert(state, range, token, token) ?? token + token
  338. return {
  339. changes: { insert, from: pos },
  340. effects:
  341. // modified by Overleaf
  342. insert === token ? [] : closeBracketEffect.of(pos + token.length),
  343. range: EditorSelection.cursor(pos + token.length),
  344. }
  345. } else if (next === token) {
  346. if (nodeStart(state, pos)) {
  347. // added by Overleaf
  348. const insert = buildInsert(state, range, token, token) ?? token + token
  349. return {
  350. changes: { insert, from: pos },
  351. effects:
  352. // modified by Overleaf
  353. insert === token ? [] : closeBracketEffect.of(pos + token.length),
  354. range: EditorSelection.cursor(pos + token.length),
  355. }
  356. } else if (closedBracketAt(state, pos)) {
  357. const isTriple =
  358. allowTriple &&
  359. state.sliceDoc(pos, pos + token.length * 3) === token + token + token
  360. const content = isTriple ? token + token + token : token
  361. return {
  362. changes: { from: pos, to: pos + content.length, insert: content },
  363. range: EditorSelection.cursor(pos + content.length),
  364. }
  365. }
  366. } else if (state.charCategorizer(pos)(next) !== CharCategory.Word) {
  367. if (
  368. canStartStringAt(state, pos, stringPrefixes) > -1 &&
  369. !probablyInString(state, pos, token, stringPrefixes)
  370. ) {
  371. // added by Overleaf
  372. const insert = buildInsert(state, range, token, token) ?? token + token
  373. return {
  374. changes: { insert, from: pos },
  375. effects:
  376. // modified by Overleaf
  377. insert === token ? [] : closeBracketEffect.of(pos + token.length),
  378. range: EditorSelection.cursor(pos + token.length),
  379. }
  380. }
  381. }
  382. return { range: (dont = range) }
  383. })
  384. return dont
  385. ? null
  386. : state.update(changes, {
  387. scrollIntoView: true,
  388. userEvent: 'input.type',
  389. })
  390. }
  391. function nodeStart(state: EditorState, pos: number) {
  392. const tree = syntaxTree(state).resolveInner(pos + 1)
  393. return tree.parent && tree.from === pos
  394. }
  395. function probablyInString(
  396. state: EditorState,
  397. pos: number,
  398. quoteToken: string,
  399. prefixes: readonly string[]
  400. ) {
  401. let node = syntaxTree(state).resolveInner(pos, -1)
  402. const maxPrefix = prefixes.reduce((m, p) => Math.max(m, p.length), 0)
  403. for (let i = 0; i < 5; i++) {
  404. const start = state.sliceDoc(
  405. node.from,
  406. Math.min(node.to, node.from + quoteToken.length + maxPrefix)
  407. )
  408. const quotePos = start.indexOf(quoteToken)
  409. if (
  410. !quotePos ||
  411. (quotePos > -1 && prefixes.indexOf(start.slice(0, quotePos)) > -1)
  412. ) {
  413. let first = node.firstChild
  414. while (
  415. first &&
  416. first.from === node.from &&
  417. first.to - first.from > quoteToken.length + quotePos
  418. ) {
  419. if (
  420. state.sliceDoc(first.to - quoteToken.length, first.to) === quoteToken
  421. )
  422. return false
  423. first = first.firstChild
  424. }
  425. return true
  426. }
  427. const parent = node.to === pos && node.parent
  428. if (!parent) break
  429. node = parent
  430. }
  431. return false
  432. }
  433. function canStartStringAt(
  434. state: EditorState,
  435. pos: number,
  436. prefixes: readonly string[]
  437. ) {
  438. const charCat = state.charCategorizer(pos)
  439. if (charCat(state.sliceDoc(pos - 1, pos)) !== CharCategory.Word) return pos
  440. for (const prefix of prefixes) {
  441. const start = pos - prefix.length
  442. if (
  443. state.sliceDoc(start, pos) === prefix &&
  444. charCat(state.sliceDoc(start - 1, start)) !== CharCategory.Word
  445. )
  446. return start
  447. }
  448. return -1
  449. }