Просмотр исходного кода

[cm6] Use a modified fork of the closeBrackets extension (#12573)

GitOrigin-RevId: a24020ed216cb10defff989f5876666c29889de2
Alf Eaton 3 лет назад
Родитель
Сommit
359255b820

+ 1 - 3
services/web/frontend/js/features/source-editor/extensions/auto-pair.ts

@@ -1,7 +1,6 @@
 import { keymap } from '@codemirror/view'
 import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
-import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
-import { closePrefixedBrackets } from './close-prefixed-brackets'
+import { closeBrackets, closeBracketsKeymap } from './close-brackets'
 
 const autoPairConf = new Compartment()
 
@@ -23,7 +22,6 @@ const createAutoPair = (enabled: boolean) => {
   }
 
   return [
-    closePrefixedBrackets(),
     closeBrackets(),
     // NOTE: using Prec.highest as this needs to run before the default Backspace handler
     Prec.highest(keymap.of(closeBracketsKeymap)),

+ 481 - 0
services/web/frontend/js/features/source-editor/extensions/close-brackets.ts

@@ -0,0 +1,481 @@
+/**
+ * This file is adapted from CodeMirror 6, licensed under the MIT license:
+ * https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
+ */
+
+import { EditorView, KeyBinding } from '@codemirror/view'
+import {
+  EditorState,
+  EditorSelection,
+  Transaction,
+  Extension,
+  StateCommand,
+  StateField,
+  StateEffect,
+  MapMode,
+  CharCategory,
+  Text,
+  codePointAt,
+  fromCodePoint,
+  codePointSize,
+  RangeSet,
+  RangeValue,
+  SelectionRange,
+} from '@codemirror/state'
+import { syntaxTree } from '@codemirror/language'
+
+/// Configures bracket closing behavior for a syntax (via
+/// [language data](#state.EditorState.languageDataAt)) using the `"closeBrackets"`
+/// identifier.
+export interface CloseBracketConfig {
+  /// The opening and closing tokens.
+  brackets?: string[]
+  /// Characters in front of which newly opened brackets are
+  /// automatically closed. Closing always happens in front of
+  /// whitespace. Defaults to `")]}:;>"`.
+  before?: string
+  /// When determining whether a given node may be a string, recognize
+  /// these prefixes before the opening quote.
+  stringPrefixes?: string[]
+  /// An optional callback for overriding the content that's inserted
+  /// based on surrounding characters
+  // added by Overleaf
+  buildInsert?: (
+    state: EditorState,
+    range: SelectionRange,
+    open: string,
+    close: string
+  ) => string
+}
+
+const defaults: Required<CloseBracketConfig> = {
+  brackets: ['(', '[', '{', "'", '"'],
+  before: ')]}:;>',
+  stringPrefixes: [],
+  // added by Overleaf
+  buildInsert: (state, range, open, close) => open + close,
+}
+
+const closeBracketEffect = StateEffect.define<number>({
+  map(value, mapping) {
+    const mapped = mapping.mapPos(value, -1, MapMode.TrackAfter)
+    return mapped === null ? undefined : mapped
+  },
+})
+
+const closedBracket = new (class extends RangeValue {})()
+closedBracket.startSide = 1
+closedBracket.endSide = -1
+
+const bracketState = StateField.define<RangeSet<typeof closedBracket>>({
+  create() {
+    return RangeSet.empty
+  },
+  update(value, tr) {
+    if (tr.selection) {
+      const lineStart = tr.state.doc.lineAt(tr.selection.main.head).from
+      const prevLineStart = tr.startState.doc.lineAt(
+        tr.startState.selection.main.head
+      ).from
+      if (lineStart !== tr.changes.mapPos(prevLineStart, -1))
+        value = RangeSet.empty
+    }
+    value = value.map(tr.changes)
+    for (const effect of tr.effects)
+      if (effect.is(closeBracketEffect))
+        value = value.update({
+          add: [closedBracket.range(effect.value, effect.value + 1)],
+        })
+    return value
+  },
+})
+
+/// Extension to enable bracket-closing behavior. When a closeable
+/// bracket is typed, its closing bracket is immediately inserted
+/// after the cursor. When closing a bracket directly in front of a
+/// closing bracket inserted by the extension, the cursor moves over
+/// that bracket.
+export function closeBrackets(): Extension {
+  return [inputHandler, bracketState]
+}
+
+const definedClosing = '()[]{}<>'
+
+function closing(ch: number) {
+  for (let i = 0; i < definedClosing.length; i += 2)
+    if (definedClosing.charCodeAt(i) === ch) return definedClosing.charAt(i + 1)
+  return fromCodePoint(ch < 128 ? ch : ch + 1)
+}
+
+function config(state: EditorState, pos: number) {
+  return (
+    state.languageDataAt<CloseBracketConfig>('closeBrackets', pos)[0] ||
+    defaults
+  )
+}
+
+const android =
+  typeof navigator === 'object' && /Android\b/.test(navigator.userAgent)
+
+const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
+  if (
+    (android ? view.composing : view.compositionStarted) ||
+    view.state.readOnly
+  )
+    return false
+  const sel = view.state.selection.main
+  if (
+    insert.length > 2 ||
+    (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
+    from !== sel.from ||
+    to !== sel.to
+  )
+    return false
+  const tr = insertBracket(view.state, insert)
+  if (!tr) return false
+  view.dispatch(tr)
+  return true
+})
+
+/// Command that implements deleting a pair of matching brackets when
+/// the cursor is between them.
+export const deleteBracketPair: StateCommand = ({ state, dispatch }) => {
+  if (state.readOnly) return false
+  const conf = config(state, state.selection.main.head)
+  const tokens = conf.brackets || defaults.brackets
+  let dont = null
+  const changes = state.changeByRange(range => {
+    if (range.empty) {
+      const before = prevChar(state.doc, range.head)
+      for (const token of tokens) {
+        if (
+          token === before &&
+          nextChar(state.doc, range.head) === closing(codePointAt(token, 0))
+        )
+          return {
+            changes: {
+              from: range.head - token.length,
+              to: range.head + token.length,
+            },
+            range: EditorSelection.cursor(range.head - token.length),
+          }
+      }
+    }
+    return { range: (dont = range) }
+  })
+  if (!dont)
+    dispatch(
+      state.update(changes, {
+        scrollIntoView: true,
+        userEvent: 'delete.backward',
+      })
+    )
+  return !dont
+}
+
+/// Close-brackets related key bindings. Binds Backspace to
+/// [`deleteBracketPair`](#autocomplete.deleteBracketPair).
+export const closeBracketsKeymap: readonly KeyBinding[] = [
+  { key: 'Backspace', run: deleteBracketPair },
+]
+
+/// Implements the extension's behavior on text insertion. If the
+/// given string counts as a bracket in the language around the
+/// selection, and replacing the selection with it requires custom
+/// behavior (inserting a closing version or skipping past a
+/// previously-closed bracket), this function returns a transaction
+/// representing that custom behavior. (You only need this if you want
+/// to programmatically insert brackets—the
+/// [`closeBrackets`](#autocomplete.closeBrackets) extension will
+/// take care of running this for user input.)
+export function insertBracket(
+  state: EditorState,
+  bracket: string
+): Transaction | null {
+  const conf = config(state, state.selection.main.head)
+  const tokens = conf.brackets || defaults.brackets
+  for (const tok of tokens) {
+    const closed = closing(codePointAt(tok, 0))
+    if (bracket === tok)
+      return closed === tok
+        ? handleSame(
+            state,
+            tok,
+            tokens.indexOf(tok + tok) > -1,
+            tokens.indexOf(tok + tok + tok) > -1,
+            conf
+          )
+        : handleOpen(state, tok, closed, conf.before || defaults.before, conf)
+
+    if (bracket === closed && closedBracketAt(state, state.selection.main.from))
+      return handleClose(state, tok, closed)
+  }
+  return null
+}
+
+function closedBracketAt(state: EditorState, pos: number) {
+  let found = false
+  state.field(bracketState).between(0, state.doc.length, from => {
+    if (from === pos) found = true
+  })
+  return found
+}
+
+function nextChar(doc: Text, pos: number) {
+  const next = doc.sliceString(pos, pos + 2)
+  return next.slice(0, codePointSize(codePointAt(next, 0)))
+}
+
+function prevChar(doc: Text, pos: number) {
+  const prev = doc.sliceString(pos - 2, pos)
+  return codePointSize(codePointAt(prev, 0)) === prev.length
+    ? prev
+    : prev.slice(1)
+}
+
+function handleOpen(
+  state: EditorState,
+  open: string,
+  close: string,
+  closeBefore: string,
+  config: CloseBracketConfig
+) {
+  // added by Overleaf
+  const buildInsert = config.buildInsert || defaults.buildInsert
+
+  let dont = null
+  const changes = state.changeByRange(range => {
+    if (!range.empty)
+      return {
+        changes: [
+          { insert: open, from: range.from },
+          { insert: close, from: range.to },
+        ],
+        effects: closeBracketEffect.of(range.to + open.length),
+        range: EditorSelection.range(
+          range.anchor + open.length,
+          range.head + open.length
+        ),
+      }
+    const next = nextChar(state.doc, range.head)
+    if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
+      // added by Overleaf
+      const insert = buildInsert(state, range, open, close) ?? open + close
+
+      return {
+        changes: { insert, from: range.head },
+        effects:
+          // modified by Overleaf
+          insert === open
+            ? []
+            : closeBracketEffect.of(range.head + open.length),
+        range: EditorSelection.cursor(range.head + open.length),
+      }
+    }
+    return { range: (dont = range) }
+  })
+  return dont
+    ? null
+    : state.update(changes, {
+        scrollIntoView: true,
+        userEvent: 'input.type',
+      })
+}
+
+function handleClose(state: EditorState, _open: string, close: string) {
+  let dont = null
+  const changes = state.changeByRange(range => {
+    if (range.empty && nextChar(state.doc, range.head) === close)
+      return {
+        changes: {
+          from: range.head,
+          to: range.head + close.length,
+          insert: close,
+        },
+        range: EditorSelection.cursor(range.head + close.length),
+      }
+    return (dont = { range })
+  })
+  return dont
+    ? null
+    : state.update(changes, {
+        scrollIntoView: true,
+        userEvent: 'input.type',
+      })
+}
+
+// Handles cases where the open and close token are the same, and
+// possibly triple quotes (as in `"""abc"""`-style quoting).
+function handleSame(
+  state: EditorState,
+  token: string,
+  // added by Overleaf
+  allowDouble: boolean,
+  allowTriple: boolean,
+  config: CloseBracketConfig
+) {
+  const stringPrefixes = config.stringPrefixes || defaults.stringPrefixes
+  // added by Overleaf
+  const buildInsert = config.buildInsert || defaults.buildInsert
+
+  let dont = null
+  const changes = state.changeByRange(range => {
+    if (!range.empty)
+      return {
+        changes: [
+          { insert: token, from: range.from },
+          { insert: token, from: range.to },
+        ],
+        effects: closeBracketEffect.of(range.to + token.length),
+        range: EditorSelection.range(
+          range.anchor + token.length,
+          range.head + token.length
+        ),
+      }
+    const pos = range.head
+    const next = nextChar(state.doc, pos)
+
+    let start
+    if (
+      allowTriple &&
+      state.sliceDoc(pos - 2 * token.length, pos) === token + token &&
+      (start = canStartStringAt(
+        state,
+        pos - 2 * token.length,
+        stringPrefixes
+      )) > -1 &&
+      nodeStart(state, start)
+    ) {
+      return {
+        changes: { insert: token + token + token + token, from: pos },
+        effects: closeBracketEffect.of(pos + token.length),
+        range: EditorSelection.cursor(pos + token.length),
+      }
+    } else if (
+      // added by Overleaf, for $$
+      allowDouble &&
+      state.sliceDoc(pos - token.length, pos) === token &&
+      (start = canStartStringAt(state, pos - token.length, stringPrefixes)) >
+        -1 &&
+      nodeStart(state, start)
+    ) {
+      // added by Overleaf
+      const insert = buildInsert(state, range, token, token) ?? token + token
+
+      return {
+        changes: { insert, from: pos },
+        effects:
+          // modified by Overleaf
+          insert === token ? [] : closeBracketEffect.of(pos + token.length),
+        range: EditorSelection.cursor(pos + token.length),
+      }
+    } else if (next === token) {
+      if (nodeStart(state, pos)) {
+        // added by Overleaf
+        const insert = buildInsert(state, range, token, token) ?? token + token
+
+        return {
+          changes: { insert, from: pos },
+          effects:
+            // modified by Overleaf
+            insert === token ? [] : closeBracketEffect.of(pos + token.length),
+          range: EditorSelection.cursor(pos + token.length),
+        }
+      } else if (closedBracketAt(state, pos)) {
+        const isTriple =
+          allowTriple &&
+          state.sliceDoc(pos, pos + token.length * 3) === token + token + token
+        const content = isTriple ? token + token + token : token
+        return {
+          changes: { from: pos, to: pos + content.length, insert: content },
+          range: EditorSelection.cursor(pos + content.length),
+        }
+      }
+    } else if (state.charCategorizer(pos)(next) !== CharCategory.Word) {
+      if (
+        canStartStringAt(state, pos, stringPrefixes) > -1 &&
+        !probablyInString(state, pos, token, stringPrefixes)
+      ) {
+        // added by Overleaf
+        const insert = buildInsert(state, range, token, token) ?? token + token
+
+        return {
+          changes: { insert, from: pos },
+          effects:
+            // modified by Overleaf
+            insert === token ? [] : closeBracketEffect.of(pos + token.length),
+          range: EditorSelection.cursor(pos + token.length),
+        }
+      }
+    }
+    return { range: (dont = range) }
+  })
+  return dont
+    ? null
+    : state.update(changes, {
+        scrollIntoView: true,
+        userEvent: 'input.type',
+      })
+}
+
+function nodeStart(state: EditorState, pos: number) {
+  const tree = syntaxTree(state).resolveInner(pos + 1)
+  return tree.parent && tree.from === pos
+}
+
+function probablyInString(
+  state: EditorState,
+  pos: number,
+  quoteToken: string,
+  prefixes: readonly string[]
+) {
+  let node = syntaxTree(state).resolveInner(pos, -1)
+  const maxPrefix = prefixes.reduce((m, p) => Math.max(m, p.length), 0)
+  for (let i = 0; i < 5; i++) {
+    const start = state.sliceDoc(
+      node.from,
+      Math.min(node.to, node.from + quoteToken.length + maxPrefix)
+    )
+    const quotePos = start.indexOf(quoteToken)
+    if (
+      !quotePos ||
+      (quotePos > -1 && prefixes.indexOf(start.slice(0, quotePos)) > -1)
+    ) {
+      let first = node.firstChild
+      while (
+        first &&
+        first.from === node.from &&
+        first.to - first.from > quoteToken.length + quotePos
+      ) {
+        if (
+          state.sliceDoc(first.to - quoteToken.length, first.to) === quoteToken
+        )
+          return false
+        first = first.firstChild
+      }
+      return true
+    }
+    const parent = node.to === pos && node.parent
+    if (!parent) break
+    node = parent
+  }
+  return false
+}
+
+function canStartStringAt(
+  state: EditorState,
+  pos: number,
+  prefixes: readonly string[]
+) {
+  const charCat = state.charCategorizer(pos)
+  if (charCat(state.sliceDoc(pos - 1, pos)) !== CharCategory.Word) return pos
+  for (const prefix of prefixes) {
+    const start = pos - prefix.length
+    if (
+      state.sliceDoc(start, pos) === prefix &&
+      charCat(state.sliceDoc(start - 1, start)) !== CharCategory.Word
+    )
+      return start
+  }
+  return -1
+}

+ 0 - 206
services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts

@@ -1,206 +0,0 @@
-/**
- * This file is adapted from CodeMirror 6, licensed under the MIT license:
- * https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
- */
-import { EditorView } from '@codemirror/view'
-import {
-  codePointAt,
-  codePointSize,
-  EditorSelection,
-  Extension,
-  SelectionRange,
-  Text,
-  TransactionSpec,
-} from '@codemirror/state'
-import { nextChar, prevChar } from '../languages/latex/completions/apply'
-import { completionStatus } from '@codemirror/autocomplete'
-import { ancestorNodeOfType } from '../utils/tree-query'
-import browser from './browser'
-
-const dispatchInput = (view: EditorView, spec: TransactionSpec) => {
-  // This is consistent with CM6's closebrackets extension and allows other
-  // extensions that check for user input to be triggered
-  view.dispatch(spec, {
-    scrollIntoView: true,
-    userEvent: 'input.type',
-  })
-
-  return true
-}
-
-const insertInput = (view: EditorView, insert: string) => {
-  const spec = view.state.changeByRange(range => {
-    return {
-      changes: [[{ from: range.from, insert }]],
-      range: EditorSelection.range(range.from + 1, range.to + 1),
-    }
-  })
-
-  return dispatchInput(view, spec)
-}
-
-const insertBracket = (view: EditorView, open: string, close: string) => {
-  const spec = view.state.changeByRange(range => {
-    if (range.empty) {
-      return {
-        changes: [{ from: range.head, insert: open + close }],
-        range: EditorSelection.cursor(range.head + open.length),
-      }
-    } else {
-      return {
-        changes: [
-          { from: range.from, insert: open },
-          { from: range.to, insert: close },
-        ],
-        range: EditorSelection.range(
-          range.anchor + open.length,
-          range.head + open.length
-        ),
-      }
-    }
-  })
-
-  return dispatchInput(view, spec)
-}
-
-export const closePrefixedBrackets = (): Extension => {
-  return EditorView.inputHandler.of((view, from, to, insert) => {
-    if (
-      (browser.android ? view.composing : view.compositionStarted) ||
-      view.state.readOnly
-    ) {
-      return false
-    }
-
-    // avoid auto-closing curly braces when autocomplete is open
-    if (insert === '{' && completionStatus(view.state)) {
-      return insertInput(view, insert)
-    }
-
-    const { doc, selection } = view.state
-
-    const sel = selection.main
-
-    if (
-      from !== sel.from ||
-      to !== sel.to ||
-      insert.length > 2 ||
-      (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1)
-    ) {
-      return false
-    }
-
-    const [config] = view.state.languageDataAt<{
-      brackets?: Record<string, string | false>
-    }>('closePrefixedBrackets', sel.head)
-
-    // no config for this language, don't handle
-    if (!config?.brackets) {
-      return false
-    }
-
-    const prevCharacter = prevChar(view.state.doc, sel.from)
-    const input = `${prevCharacter}${insert}`
-    const close = config.brackets[input] ?? config.brackets[insert]
-
-    // not specified, don't handle
-    if (close === undefined) {
-      return false
-    }
-
-    // prevent auto-close, just insert the character
-    if (close === false) {
-      return insertInput(view, insert)
-    }
-
-    const nextCharacter = nextChar(doc, sel.from)
-
-    if (insert === '$') {
-      // avoid duplicating a math-closing dollar sign
-      if (moveOverClosingMathDollar(view, sel)) {
-        return true
-      }
-
-      // avoid creating an odd number of dollar signs
-      const count = countSurroundingCharacters(doc, sel.from, insert)
-      if (count % 2 !== 0) {
-        return insertInput(view, insert)
-      }
-    }
-
-    // This is the default set of "before" characters from the closeBrackets extension,
-    // plus $ (so $$ works as expected)
-    if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) {
-      // auto-close
-      return insertBracket(view, insert, close)
-    }
-
-    return false
-  })
-}
-
-const moveOverClosingMathDollar = (
-  view: EditorView,
-  sel: SelectionRange
-): boolean => {
-  if (!sel.empty) {
-    return false
-  }
-
-  // inside dollar math
-  const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath')
-  if (!outerNode) {
-    return false
-  }
-
-  // not display math
-  const innerNode = outerNode.getChild('InlineMath')
-  if (!innerNode) {
-    return false
-  }
-
-  // the cursor is at the end of the InlineMath node
-  if (sel.from !== innerNode.to) {
-    return false
-  }
-
-  // there's already some math content
-  const content = view.state.doc.sliceString(innerNode.from, innerNode.to)
-  if (content.length === 0) {
-    return false
-  }
-
-  // move the cursor outside the DollarMath node
-  view.dispatch({
-    selection: EditorSelection.cursor(outerNode.to),
-  })
-  return true
-}
-
-const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => {
-  let count = 0
-
-  // count backwards
-  let to = pos
-  do {
-    const char = doc.sliceString(to - 1, to)
-    if (char !== insert) {
-      break
-    }
-    count++
-    to--
-  } while (to > 1)
-
-  // count forwards
-  let from = pos
-  do {
-    const char = doc.sliceString(from, from + 1)
-    if (char !== insert) {
-      break
-    }
-    count++
-    from++
-  } while (from < doc.length)
-
-  return count
-}

+ 123 - 0
services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts

@@ -0,0 +1,123 @@
+import { CloseBracketConfig } from '../../extensions/close-brackets'
+import {
+  codePointAt,
+  codePointSize,
+  EditorState,
+  SelectionRange,
+  Text,
+} from '@codemirror/state'
+import { completionStatus } from '@codemirror/autocomplete'
+
+export const closeBracketConfig: CloseBracketConfig = {
+  brackets: ['$', '$$', '[', '{', '('],
+  buildInsert(
+    state: EditorState,
+    range: SelectionRange,
+    open: string,
+    close: string
+  ): string {
+    switch (open) {
+      // close for $ or $$
+      case '$': {
+        const prev = prevChar(state.doc, range.head)
+        if (prev === '\\') {
+          const preprev = prevChar(state.doc, range.head - prev.length)
+          // add an unprefixed closing dollar to \\$
+          if (preprev === '\\') {
+            return open + '$'
+          }
+          // don't auto-close \$
+          return open
+        }
+        // avoid creating an odd number of dollar signs
+        const count = countSurroundingCharacters(state.doc, range.from, open)
+        if (count % 2 !== 0) {
+          return open
+        }
+        return open + close
+      }
+
+      // close for [ or \[
+      case '[': {
+        const prev = prevChar(state.doc, range.head)
+        if (prev === '\\') {
+          const preprev = prevChar(state.doc, range.head - prev.length)
+          // add an unprefixed closing bracket to \\[
+          if (preprev === '\\') {
+            return open + ']'
+          }
+          return open + '\\' + close
+        }
+        return open + close
+      }
+
+      // only close for \(
+      case '(': {
+        const prev = prevChar(state.doc, range.head)
+        if (prev === '\\') {
+          const preprev = prevChar(state.doc, range.head - prev.length)
+          // don't auto-close \\(
+          if (preprev === '\\') {
+            return open
+          }
+          return open + '\\' + close
+        }
+        return open
+      }
+
+      // only close for {
+      case '{': {
+        const prev = prevChar(state.doc, range.head)
+        if (prev === '\\') {
+          const preprev = prevChar(state.doc, range.head - prev.length)
+          // add an unprefixed closing bracket to \\{
+          if (preprev === '\\') {
+            return open + '}'
+          }
+          // don't auto-close \{
+          return open
+        }
+        // avoid auto-closing curly brackets when autocomplete is open
+        if (completionStatus(state)) {
+          return open
+        }
+        return open + close
+      }
+
+      default:
+        return open + close
+    }
+  },
+}
+
+function prevChar(doc: Text, pos: number) {
+  const prev = doc.sliceString(pos - 2, pos)
+  return codePointSize(codePointAt(prev, 0)) === prev.length
+    ? prev
+    : prev.slice(1)
+}
+
+function countSurroundingCharacters(doc: Text, pos: number, insert: string) {
+  let count = 0
+  // count backwards
+  let to = pos
+  do {
+    const char = doc.sliceString(to - insert.length, to)
+    if (char !== insert) {
+      break
+    }
+    count++
+    to--
+  } while (to > 1)
+  // count forwards
+  let from = pos
+  do {
+    const char = doc.sliceString(from, from + insert.length)
+    if (char !== insert) {
+      break
+    }
+    count++
+    from++
+  } while (from < doc.length)
+  return count
+}

+ 2 - 13
services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts

@@ -9,6 +9,7 @@ import {
   findClosingFoldComment,
   getFoldRange,
 } from '../../utils/tree-query'
+import { closeBracketConfig } from './close-bracket-config'
 
 const styleOverrides: Record<string, any> = {
   DocumentClassCtrlSeq: t.keyword,
@@ -173,18 +174,6 @@ export const LaTeXLanguage = LRLanguage.define({
   }),
   languageData: {
     commentTokens: { line: '%' },
-    closeBrackets: { brackets: ['[', '{'] },
-    closePrefixedBrackets: {
-      brackets: {
-        // $$ will produce $$ $$, but we set a single closing $ sign as the value
-        // because inserting $ will already have added a closing bracket.
-        $$: '$',
-        $: '$',
-        '\\(': '\\)',
-        '\\[': '\\]',
-        '\\$': false,
-        '\\{': false,
-      },
-    },
+    closeBrackets: closeBracketConfig,
   },
 })

+ 192 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-close-brackets.spec.tsx

@@ -0,0 +1,192 @@
+import { FC } from 'react'
+import { mockScope } from '../helpers/mock-scope'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
+
+const Container: FC = ({ children }) => (
+  <div style={{ width: 785, height: 785 }}>{children}</div>
+)
+
+describe('close brackets', { scrollBehavior: false }, function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+    cy.interceptEvents()
+    cy.interceptSpelling()
+
+    const scope = mockScope()
+
+    cy.mount(
+      <Container>
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </Container>
+    )
+
+    cy.get('.cm-line').eq(20).click().as('active-line')
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  describe('unprefixed characters', function () {
+    it('auto-closes a curly bracket', function () {
+      cy.get('@active-line')
+        .type('{{}')
+        .should('have.text', '{}')
+        .type('{backspace}')
+        .should('have.text', '')
+    })
+
+    it('auto-closes a square bracket', function () {
+      cy.get('@active-line')
+        .type('[')
+        .should('have.text', '[]')
+        .type('{backspace}')
+        .should('have.text', '')
+    })
+
+    it('does not auto-close a round bracket', function () {
+      cy.get('@active-line').type('(').should('have.text', '(')
+    })
+
+    it('auto-closes a dollar sign', function () {
+      cy.get('@active-line')
+        .type('$')
+        .should('have.text', '$$')
+        .type('{backspace}')
+        .should('have.text', '')
+    })
+
+    it('auto-closes another dollar sign', function () {
+      cy.get('@active-line')
+        .type('$$')
+        .should('have.text', '$$$$')
+        .type('{backspace}{backspace}')
+        .should('have.text', '')
+    })
+
+    it('avoids creating an odd number of adjacent dollar signs', function () {
+      cy.get('@active-line')
+        .type('$2')
+        .should('have.text', '$2$')
+        .type('{leftArrow}$')
+        .should('have.text', '$$2$')
+    })
+  })
+
+  describe('prefixed characters', function () {
+    it('auto-closes a backslash-prefixed round bracket', function () {
+      cy.get('@active-line').type('\\(').should('have.text', '\\(\\)')
+    })
+
+    it('auto-closes a backslash-prefixed square bracket', function () {
+      cy.get('@active-line').type('\\[').should('have.text', '\\[\\]')
+    })
+
+    it('does not auto-close a backslash-prefixed curly bracket', function () {
+      cy.get('@active-line').type('\\{{}').should('have.text', '\\{')
+    })
+
+    it('does not auto-close a backslash-prefixed dollar sign', function () {
+      cy.get('@active-line').type('\\$').should('have.text', '\\$')
+    })
+  })
+
+  describe('double-prefixed characters', function () {
+    it('auto-closes a double-backslash-prefixed square bracket with a square bracket', function () {
+      cy.get('@active-line').type('\\\\[').should('have.text', '\\\\[]')
+    })
+
+    it('auto-closes a double-backslash-prefixed curly bracket with a curly bracket', function () {
+      cy.get('@active-line').type('\\\\{').should('have.text', '\\\\{}')
+    })
+
+    it('auto-closes a double-backslash-prefixed dollar sign with a dollar sign', function () {
+      cy.get('@active-line').type('\\\\$').should('have.text', '\\\\$$')
+    })
+
+    it('does not auto-close a double-backslash-prefixed round bracket', function () {
+      cy.get('@active-line').type('\\\\(').should('have.text', '\\\\(')
+    })
+  })
+
+  describe('adjacent characters', function () {
+    it('does auto-close a dollar sign before punctuation', function () {
+      cy.get('@active-line')
+        .type(':2')
+        .type('{leftArrow}{leftArrow}$')
+        .should('have.text', '$$:2')
+    })
+
+    it('does auto-close a dollar sign after punctuation', function () {
+      cy.get('@active-line').type('2:').type('$').should('have.text', '2:$$')
+    })
+
+    it('does not auto-close a dollar sign before text', function () {
+      cy.get('@active-line')
+        .type('2')
+        .type('{leftArrow}$')
+        .should('have.text', '$2')
+    })
+
+    it('does not auto-close a dollar sign after text', function () {
+      cy.get('@active-line').type('2').type('$').should('have.text', '2$')
+    })
+
+    it('does auto-close a curly bracket before punctuation', function () {
+      cy.get('@active-line')
+        .type(':2')
+        .type('{leftArrow}{leftArrow}{{}')
+        .should('have.text', '{}:2')
+    })
+
+    it('does auto-close a curly bracket after punctuation', function () {
+      cy.get('@active-line').type('2:').type('{{}').should('have.text', '2:{}')
+    })
+
+    it('does not auto-close a curly bracket before text', function () {
+      cy.get('@active-line')
+        .type('2')
+        .type('{leftArrow}{{}')
+        .should('have.text', '{2')
+    })
+
+    it('does auto-close a curly bracket after text', function () {
+      cy.get('@active-line').type('2').type('{{}').should('have.text', '2{}')
+    })
+
+    it('does auto-close $$ before punctuation', function () {
+      cy.get('@active-line')
+        .type(':2')
+        .type('{leftArrow}{leftArrow}$$')
+        .should('have.text', '$$$$:2')
+    })
+
+    it('does not auto-close $$ before text', function () {
+      cy.get('@active-line')
+        .type('2')
+        .type('{leftArrow}$$')
+        .should('have.text', '$$2')
+    })
+  })
+
+  describe('closed brackets', function () {
+    it('does type over a closing dollar sign', function () {
+      cy.get('@active-line').type('$2$').should('have.text', '$2$')
+    })
+
+    it('does type over two closing dollar signs', function () {
+      cy.get('@active-line').type('$$2$$').should('have.text', '$$2$$')
+    })
+
+    it('does type over a closing curly bracket', function () {
+      cy.get('@active-line').type('{{}2}').should('have.text', '{2}')
+    })
+
+    it('does type over a closing square bracket', function () {
+      cy.get('@active-line').type('[2]').should('have.text', '[2]')
+    })
+  })
+})

+ 0 - 133
services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx

@@ -445,139 +445,6 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
     cy.findByRole('button', { name: 'Close' }).click()
   })
 
-  it('auto-closes custom brackets', function () {
-    const scope = mockScope()
-
-    cy.mount(
-      <Container>
-        <EditorProviders scope={scope}>
-          <CodeMirrorEditor />
-        </EditorProviders>
-      </Container>
-    )
-
-    // put the cursor on a blank line to type in
-    cy.get('.cm-line').eq(16).click().as('line')
-
-    // { auto-closes
-    cy.get('@line').type('{{}') // NOTE: {{} = literal {
-    cy.get('@line').should('have.text', '{}')
-    cy.get('@line').type('{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // [ auto-closes
-    cy.get('@line').type('[')
-    cy.get('@line').should('have.text', '[]')
-    cy.get('@line').type('{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // $ auto-closes
-    cy.get('@line').type('$')
-    cy.get('@line').should('have.text', '$$')
-    cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // $$ auto-closes
-    cy.get('@line').type('$$')
-    cy.get('@line').should('have.text', '$$$$')
-    cy.get('@line').type('{rightArrow}{rightArrow}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '$$')
-    cy.get('@line').type('{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // \{ doesn't auto-close
-    cy.get('@line').type('\\{{}')
-    cy.get('@line').should('have.text', '\\{')
-    cy.get('@line').type('{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // \[ *does* auto-close
-    cy.get('@line').type('\\[')
-    cy.get('@line').should('have.text', '\\[\\]')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // \( *does* auto-close
-    cy.get('@line').type('\\(')
-    cy.get('@line').should('have.text', '\\(\\)')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // \$ doesn't auto-close
-    cy.get('@line').type('\\$')
-    cy.get('@line').should('have.text', '\\$')
-    cy.get('@line').type('{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // { doesn't auto-close in front of an alphanumeric character
-    cy.get('@line').type('2{leftArrow}{{}')
-    cy.get('@line').should('have.text', '{2')
-    cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // [ doesn't auto-close in front of an alphanumeric character
-    cy.get('@line').type('2{leftArrow}[')
-    cy.get('@line').should('have.text', '[2')
-    cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // $ doesn't auto-close in front of an alphanumeric character
-    cy.get('@line').type('2{leftArrow}$')
-    cy.get('@line').should('have.text', '$2')
-    cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // $$ doesn't auto-close in front of an alphanumeric character
-    cy.get('@line').type('2{leftArrow}$$')
-    cy.get('@line').should('have.text', '$$2')
-    cy.get('@line').type('{rightArrow}{Backspace}{Backspace}{Backspace}')
-    cy.get('@line').should('have.text', '')
-
-    // { does auto-close in front of a known character
-    cy.get('@line').type(':{leftArrow}{{}')
-    cy.get('@line').should('have.text', '{}:')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // [ does auto-close in front of a known character
-    cy.get('@line').type(':{leftArrow}[')
-    cy.get('@line').should('have.text', '[]:')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // $ does auto-close in front of a known character
-    cy.get('@line').type(':{leftArrow}$')
-    cy.get('@line').should('have.text', '$$:')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // $$ does auto-close in front of a known character
-    cy.get('@line').type(':{leftArrow}$$')
-    cy.get('@line').should('have.text', '$$$$:')
-    cy.get('@line').type(
-      '{rightArrow}{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}'
-    )
-    cy.get('@line').should('have.text', '')
-
-    // $ at the end of an inline "dollar math" node skips the closing $
-    cy.get('@line').type('$2+3=5')
-    cy.get('@line').should('have.text', '$2+3=5$')
-    cy.get('@line').type('$')
-    cy.get('@line').should('have.text', '$2+3=5$')
-    cy.get('@line').type('{Backspace}'.repeat(7))
-    cy.get('@line').should('have.text', '')
-  })
-
   it('navigates in the search panel', function () {
     const scope = mockScope()