ソースを参照

[cm6] Handle multiple selection ranges when applying snippets (#12830)

* Use fork of @codemirror/autocomplete
* Handle multiple selection ranges when applying snippets

GitOrigin-RevId: 04afc087ac127206463ea3d4950284a50308364a
Alf Eaton 3 年 前
コミット
6c21f0821c
18 ファイル変更202 行追加744 行削除
  1. 9 8
      package-lock.json
  2. 8 14
      services/web/frontend/js/features/source-editor/extensions/auto-pair.ts
  3. 0 481
      services/web/frontend/js/features/source-editor/extensions/close-brackets.ts
  4. 5 15
      services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts
  5. 83 136
      services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts
  6. 2 2
      services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts
  7. 2 2
      services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts
  8. 9 9
      services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts
  9. 15 16
      services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts
  10. 14 11
      services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts
  11. 2 2
      services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts
  12. 18 18
      services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts
  13. 2 2
      services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts
  14. 14 14
      services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts
  15. 4 1
      services/web/frontend/js/features/source-editor/languages/latex/snippets.ts
  16. 1 1
      services/web/package.json
  17. 11 11
      services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx
  18. 3 1
      services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx

+ 9 - 8
package-lock.json

@@ -3145,9 +3145,10 @@
       "dev": true
     },
     "node_modules/@codemirror/autocomplete": {
-      "version": "6.5.1",
-      "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz",
-      "integrity": "sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==",
+      "version": "6.6.0",
+      "resolved": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
+      "integrity": "sha512-Uf8Tv/wLUmPyxTwxrp/+t+f5sognQs+qHyQ+AvCFhS1wWa2Tw9+Ko/84EsPXQpYwTgN8xvejBCSHlRzubogHLA==",
+      "license": "MIT",
       "dependencies": {
         "@codemirror/language": "^6.0.0",
         "@codemirror/state": "^6.0.0",
@@ -35168,7 +35169,7 @@
         "@babel/preset-env": "^7.14.5",
         "@babel/preset-react": "^7.14.5",
         "@babel/preset-typescript": "^7.16.0",
-        "@codemirror/autocomplete": "^6.5.1",
+        "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
         "@codemirror/commands": "^6.2.3",
         "@codemirror/lang-markdown": "^6.1.1",
         "@codemirror/language": "^6.6.0",
@@ -39929,9 +39930,9 @@
       "dev": true
     },
     "@codemirror/autocomplete": {
-      "version": "6.5.1",
-      "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz",
-      "integrity": "sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==",
+      "version": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
+      "integrity": "sha512-Uf8Tv/wLUmPyxTwxrp/+t+f5sognQs+qHyQ+AvCFhS1wWa2Tw9+Ko/84EsPXQpYwTgN8xvejBCSHlRzubogHLA==",
+      "from": "@codemirror/autocomplete@github:overleaf/codemirror-autocomplete#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
       "requires": {
         "@codemirror/language": "^6.0.0",
         "@codemirror/state": "^6.0.0",
@@ -44848,7 +44849,7 @@
         "@babel/preset-react": "^7.14.5",
         "@babel/preset-typescript": "^7.16.0",
         "@babel/register": "^7.14.5",
-        "@codemirror/autocomplete": "^6.5.1",
+        "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
         "@codemirror/commands": "^6.2.3",
         "@codemirror/lang-markdown": "^6.1.1",
         "@codemirror/language": "^6.6.0",

+ 8 - 14
services/web/frontend/js/features/source-editor/extensions/auto-pair.ts

@@ -1,6 +1,6 @@
 import { keymap } from '@codemirror/view'
 import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
-import { closeBrackets, closeBracketsKeymap } from './close-brackets'
+import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
 
 const autoPairConf = new Compartment()
 
@@ -8,22 +8,16 @@ export const autoPair = ({
   autoPairDelimiters,
 }: {
   autoPairDelimiters: boolean
-}) => autoPairConf.of(createAutoPair(autoPairDelimiters))
+}) => autoPairConf.of(autoPairDelimiters ? extension : [])
 
 export const setAutoPair = (autoPairDelimiters: boolean): TransactionSpec => {
   return {
-    effects: autoPairConf.reconfigure(createAutoPair(autoPairDelimiters)),
+    effects: autoPairConf.reconfigure(autoPairDelimiters ? extension : []),
   }
 }
 
-const createAutoPair = (enabled: boolean) => {
-  if (!enabled) {
-    return []
-  }
-
-  return [
-    closeBrackets(),
-    // NOTE: using Prec.highest as this needs to run before the default Backspace handler
-    Prec.highest(keymap.of(closeBracketsKeymap)),
-  ]
-}
+const extension = [
+  closeBrackets(),
+  // NOTE: using Prec.highest as this needs to run before the default Backspace handler
+  Prec.highest(keymap.of(closeBracketsKeymap)),
+]

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

@@ -1,481 +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, 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
-}

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

@@ -1,12 +1,9 @@
-import { CloseBracketConfig } from '../../extensions/close-brackets'
+import { EditorState, SelectionRange, Text } from '@codemirror/state'
 import {
-  codePointAt,
-  codePointSize,
-  EditorState,
-  SelectionRange,
-  Text,
-} from '@codemirror/state'
-import { completionStatus } from '@codemirror/autocomplete'
+  CloseBracketConfig,
+  completionStatus,
+  prevChar,
+} from '@codemirror/autocomplete'
 
 export const closeBracketConfig: CloseBracketConfig = {
   brackets: ['$', '$$', '[', '{', '('],
@@ -90,13 +87,6 @@ export const closeBracketConfig: CloseBracketConfig = {
   },
 }
 
-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

+ 83 - 136
services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts

@@ -1,82 +1,27 @@
+import { EditorState, Text } from '@codemirror/state'
 import {
-  codePointAt,
-  codePointSize,
-  EditorSelection,
-  EditorState,
-  Text,
-  TransactionSpec,
-} from '@codemirror/state'
-import { clearSnippet, Completion, snippet } from '@codemirror/autocomplete'
+  clearSnippet,
+  Completion,
+  snippet,
+  nextChar,
+} from '@codemirror/autocomplete'
 import { EditorView } from '@codemirror/view'
 import { prepareSnippetTemplate } from '../snippets'
 import { ancestorNodeOfType } from '../../../utils/tree-query'
 
-// from https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
-export const nextChar = (doc: Text, pos: number) => {
-  const next = doc.sliceString(pos, pos + 2)
-  return next.slice(0, codePointSize(codePointAt(next, 0)))
-}
-
-export const prevChar = (doc: Text, pos: number) => {
-  const prev = doc.sliceString(pos - 2, pos)
-  return codePointSize(codePointAt(prev, 0)) === prev.length
-    ? prev
-    : prev.slice(1)
-}
-
-// from https://github.com/codemirror/autocomplete/blob/6.4.2/src/completion.ts
-// forked due to an issue with `to` in https://github.com/codemirror/autocomplete/commit/a4cce022daea903c8b9ffcb7ca2fb598b17bfb66
-export function insertCompletionText(
-  state: EditorState,
-  text: string,
-  from: number,
-  to: number
-): TransactionSpec {
-  return {
-    ...state.changeByRange(range => {
-      if (range === state.selection.main) {
-        return {
-          changes: { from, to, insert: text },
-          range: EditorSelection.cursor(from + text.length),
-        }
-      }
-      if (!range.empty) {
-        return { range }
-      }
-      const len = to - from
-      if (
-        len &&
-        state.sliceDoc(range.from - len, range.from) !==
-          state.sliceDoc(from, to)
-      ) {
-        return { range }
-      }
-      return {
-        changes: { from: range.from - len, to: range.from, insert: text },
-        range: EditorSelection.cursor(range.from - len + text.length),
-      }
-    }),
-    userEvent: 'input.complete',
-  }
-}
-
-// Apply a completed command, removing any subsequent closing brace, optionally
-// providing a function that generates the completion text. If missing, the
-// completion label is used.
-export const createCommandApplier =
-  (text: string) =>
-  (view: EditorView, completion: Completion, from: number, to: number) => {
-    const { doc } = view.state
-
-    // extend forwards to cover an unpaired closing brace
-    if (nextChar(doc, to) === '}') {
-      if (countUnclosedBraces(doc, from, to) < 0) {
-        to++
-      }
+export const applySnippet = (template: string, clear = false) => {
+  return (
+    view: EditorView,
+    completion: Completion,
+    from: number,
+    to: number
+  ) => {
+    snippet(prepareSnippetTemplate(template))(view, completion, from, to)
+    if (clear) {
+      clearSnippet(view)
     }
-    // TODO: extend `to` to cover more subsequent characters?
-    view.dispatch(insertCompletionText(view.state, text, from, to))
   }
+}
 
 const longestCommonPrefix = (...strs: string[]) => {
   if (strs.length === 0) {
@@ -93,68 +38,82 @@ const longestCommonPrefix = (...strs: string[]) => {
   return minLength
 }
 
-// apply a completed required parameter, adding a closing brace and extending the range if needed
-export const createRequiredParameterApplier =
-  (text: string) =>
-  (view: EditorView, completion: Completion, from: number, to: number) => {
-    const { doc } = view.state
-    const argumentNode = ancestorNodeOfType(view.state, from, '$Argument')
-    const isWellFormedArgumentNode =
-      argumentNode &&
-      argumentNode.getChild('OpenBrace') &&
-      argumentNode.getChild('CloseBrace')
+// extend forwards to cover an unpaired closing brace
+export const extendRequiredParameter = (
+  state: EditorState,
+  change: {
+    from: number
+    to: number
+    insert: string | Text
+  }
+) => {
+  if (typeof change.insert !== 'string') {
+    return change
+  }
 
-    // add a closing brace if needed
-    if (nextChar(doc, to) !== '}') {
-      if (countUnclosedBraces(doc, from, to) > 0) {
-        text += '}'
-      }
+  const argumentNode = ancestorNodeOfType(state, change.from, '$Argument')
+  const isWellFormedArgumentNode =
+    argumentNode &&
+    argumentNode.getChild('OpenBrace') &&
+    argumentNode.getChild('CloseBrace')
 
-      if (isWellFormedArgumentNode) {
-        // extend over subsequent text that isn't a brace, space, or comma
-        const match = doc
-          .sliceString(to, Math.min(doc.lineAt(from).to, argumentNode.to))
-          .match(/^[^}\s,]+/)
-        if (match) {
-          to += match[0].length
-        }
-      } else {
-        // Ensure we don't swallow a closing brace
-        const restOfLine = doc
-          .sliceString(to, Math.min(doc.lineAt(from).to, from + text.length))
-          .split('}')[0]
+  // add a closing brace if needed
+  if (nextChar(state.doc, change.to) !== '}') {
+    if (countUnclosedBraces(state.doc, change.from, change.to) > 0) {
+      change.insert += '}'
+    }
 
-        to += longestCommonPrefix(text.slice(to - from), restOfLine)
+    if (isWellFormedArgumentNode) {
+      // extend over subsequent text that isn't a brace, space, or comma
+      const match = state.doc
+        .sliceString(
+          change.to,
+          Math.min(state.doc.lineAt(change.from).to, argumentNode.to)
+        )
+        .match(/^[^}\s,]+/)
+      if (match) {
+        change.to += match[0].length
       }
+    } else {
+      // Ensure we don't swallow a closing brace
+      const restOfLine = state.doc
+        .sliceString(
+          change.to,
+          Math.min(
+            state.doc.lineAt(change.from).to,
+            change.from + change.insert.length
+          )
+        )
+        .split('}')[0]
+
+      change.to += longestCommonPrefix(
+        change.insert.slice(change.to - change.from),
+        restOfLine
+      )
     }
-
-    view.dispatch(insertCompletionText(view.state, text, from, to))
   }
+  return change
+}
 
-/*
- * Handle trailing '}' characters and tabbing back from final placeholder when
- * inserting snippets
- */
-const customSnippetApply = (template: string, clear = false) => {
-  return (
-    view: EditorView,
-    completion: Completion,
-    from: number,
+// extend forwards to cover an unpaired closing brace
+export const extendOverUnpairedClosingBrace = (
+  state: EditorState,
+  change: {
+    from: number
     to: number
-  ) => {
-    // extend forwards to cover an unpaired closing brace
-    if (nextChar(view.state.doc, to) === '}') {
-      if (countUnclosedBraces(view.state.doc, from, to) < 0) {
-        to++
-      }
-    }
-
-    const snippetApply = snippet(prepareSnippetTemplate(template))
-    snippetApply(view, completion, from, to)
-    if (clear) {
-      clearSnippet(view)
+  }
+) => {
+  if (nextChar(state.doc, change.to) === '}') {
+    const unclosedBraces = countUnclosedBraces(
+      state.doc,
+      change.from,
+      change.to
+    )
+    if (unclosedBraces < 0) {
+      change.to++
     }
   }
+  return change
 }
 
 const countUnclosedBraces = (doc: Text, from: number, to: number): number => {
@@ -176,15 +135,3 @@ const countUnclosedBraces = (doc: Text, from: number, to: number): number => {
 
   return openBraces - closedBraces
 }
-
-// Convert from Ace `$1` to CodeMirror numbered placeholder format `${1}` or `#{1}` in snippets.
-// Note: metadata from the server still uses the old format, so it's not enough to convert all
-// the bundled data to the new format.
-export const customSnippetCompletion = (
-  template: string,
-  completion: Completion,
-  clear = false
-) => {
-  completion.apply = customSnippetApply(template, clear)
-  return completion
-}

+ 2 - 2
services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts

@@ -1,5 +1,5 @@
 import { Completions } from './types'
-import { createRequiredParameterApplier } from './apply'
+import { extendRequiredParameter } from './apply'
 import { bibliographyStyles } from './data/bibliography-styles'
 
 const values = Object.values(bibliographyStyles).flat()
@@ -11,7 +11,7 @@ export function buildBibliographyStyleCompletions(completions: Completions) {
     completions.bibliographyStyles.push({
       type: 'bib',
       label: item,
-      apply: createRequiredParameterApplier(item),
+      extend: extendRequiredParameter,
     })
   }
 }

+ 2 - 2
services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts

@@ -1,4 +1,4 @@
-import { createRequiredParameterApplier } from './apply'
+import { extendRequiredParameter } from './apply'
 import { classNames } from './data/class-names'
 import { Completions } from './types'
 
@@ -7,7 +7,7 @@ export function buildClassCompletions(completions: Completions) {
     completions.classes.push({
       type: 'pkg',
       label: item,
-      apply: createRequiredParameterApplier(item),
+      extend: extendRequiredParameter,
     })
   }
 }

+ 9 - 9
services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts

@@ -1,4 +1,4 @@
-import { customSnippetCompletion } from './apply'
+import { applySnippet, extendOverUnpairedClosingBrace } from './apply'
 import { Completion, CompletionContext } from '@codemirror/autocomplete'
 import { documentCommands } from '../document-commands'
 import { Command } from '../../../utils/tree-operations/commands'
@@ -16,19 +16,19 @@ export function customCommandCompletions(
       .filter(Boolean)
   )
 
-  const output = []
+  const output: Completion[] = []
 
   const items = countCommandUsage(context)
 
   for (const item of items.values()) {
     if (!existingCommands.has(commandNameFromLabel(item.label))) {
-      output.push(
-        customSnippetCompletion(item.snippet, {
-          type: 'cmd',
-          label: item.label,
-          boost: item.count - 10,
-        })
-      )
+      output.push({
+        type: 'cmd',
+        label: item.label,
+        boost: item.count - 10,
+        apply: applySnippet(item.snippet),
+        extend: extendOverUnpairedClosingBrace,
+      })
     }
   }
 

+ 15 - 16
services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts

@@ -1,5 +1,5 @@
 import { environments, snippet } from './data/environments'
-import { customSnippetCompletion, createCommandApplier } from './apply'
+import { applySnippet, extendOverUnpairedClosingBrace } from './apply'
 import { Completion, CompletionContext } from '@codemirror/autocomplete'
 import { Completions } from './types'
 
@@ -8,31 +8,31 @@ import { Completions } from './types'
  */
 export function buildEnvironmentCompletions(completions: Completions) {
   for (const [item, snippet] of environments) {
-    completions.commands.push(
-      customSnippetCompletion(
-        snippet,
-        {
-          type: 'env',
-          label: `\\begin{${item}} …`,
-        },
-        // clear snippet for some environments after inserting
-        item === 'itemize' || item === 'enumerate'
-      )
-    )
+    // clear snippet for some environments after inserting
+    const clear =
+      item === 'abstract' || item === 'itemize' || item === 'enumerate'
+    completions.commands.push({
+      type: 'env',
+      label: `\\begin{${item}} …`,
+      apply: applySnippet(snippet, clear),
+      extend: extendOverUnpairedClosingBrace,
+    })
   }
 }
 
 /**
  * A `begin` environment completion with a snippet, for the current context
  */
-export function customBeginCompletion(name: string) {
+export function customBeginCompletion(name: string): Completion | null {
   if (environments.has(name)) {
     return null
   }
 
-  return customSnippetCompletion(snippet(name), {
+  return {
     label: `\\begin{${name}} …`,
-  })
+    apply: applySnippet(snippet(name)),
+    extend: extendOverUnpairedClosingBrace,
+  }
 }
 
 /**
@@ -61,7 +61,6 @@ export function customEndCompletions(context: CompletionContext): Completion[] {
     completions.push({
       label: env,
       boost: boost++, // environments opened later rank higher
-      apply: createCommandApplier(env),
     })
   }
 

+ 14 - 11
services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts

@@ -1,5 +1,8 @@
 import { CompletionContext } from '@codemirror/autocomplete'
-import { createCommandApplier, createRequiredParameterApplier } from './apply'
+import {
+  extendOverUnpairedClosingBrace,
+  extendRequiredParameter,
+} from './apply'
 import { Folder } from '../../../../../../../types/folder'
 import { Completions } from './types'
 import { metadataState } from '../../../extensions/language'
@@ -34,21 +37,24 @@ export function buildIncludeCompletions(
       completions.includes.push({
         type: 'file',
         label: path,
-        apply: createRequiredParameterApplier(removeTexExtension(path)),
+        apply: removeTexExtension(path),
+        extend: extendRequiredParameter,
       })
 
       // \include{path}
       completions.commands.push({
         type: 'cmd',
         label: `\\include{${path}}`,
-        apply: createCommandApplier(`\\include{${removeTexExtension(path)}}`),
+        apply: `\\include{${removeTexExtension(path)}}`,
+        extend: extendOverUnpairedClosingBrace,
       })
 
       // \input{path}
       completions.commands.push({
         type: 'cmd',
         label: `\\input{${path}}`,
-        apply: createCommandApplier(`\\input{${removeTexExtension(path)}}`),
+        apply: `\\input{${removeTexExtension(path)}}`,
+        extend: extendOverUnpairedClosingBrace,
       })
     }
 
@@ -58,16 +64,13 @@ export function buildIncludeCompletions(
       completions.graphics.push({
         type: 'file',
         label: path,
-        apply: createRequiredParameterApplier(path), // TODO: remove extension?
+        extend: extendRequiredParameter,
       })
 
-      const label = `\\includegraphics{${path}}`
-
-      // \includegraphics{path}
       completions.commands.push({
         type: 'cmd',
-        label,
-        apply: createCommandApplier(label),
+        label: `\\includegraphics{${path}}`,
+        extend: extendOverUnpairedClosingBrace,
       })
     }
 
@@ -77,7 +80,7 @@ export function buildIncludeCompletions(
       completions.bibliographies.push({
         type: 'bib',
         label,
-        apply: createRequiredParameterApplier(label),
+        extend: extendRequiredParameter,
       })
     }
   }

+ 2 - 2
services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts

@@ -1,4 +1,4 @@
-import { createRequiredParameterApplier } from './apply'
+import { extendRequiredParameter } from './apply'
 import { Completions } from './types'
 import { metadataState } from '../../../extensions/language'
 import { CompletionContext } from '@codemirror/autocomplete'
@@ -26,7 +26,7 @@ export function buildLabelCompletions(
     completions.labels.push({
       type: 'label',
       label,
-      apply: createRequiredParameterApplier(label),
+      extend: extendRequiredParameter,
     })
   }
 }

+ 18 - 18
services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts

@@ -1,7 +1,7 @@
 import {
-  customSnippetCompletion,
-  createRequiredParameterApplier,
-  createCommandApplier,
+  applySnippet,
+  extendOverUnpairedClosingBrace,
+  extendRequiredParameter,
 } from './apply'
 import { packageNames } from './data/package-names'
 import { Completions } from './types'
@@ -29,12 +29,12 @@ export function buildPackageCompletions(
       uniquePackageNames.add(packageName)
 
       for (const item of commands) {
-        completions.commands.push(
-          customSnippetCompletion(item.snippet, {
-            type: item.meta,
-            label: item.caption,
-          })
-        )
+        completions.commands.push({
+          type: item.meta,
+          label: item.caption,
+          apply: applySnippet(item.snippet),
+          extend: extendOverUnpairedClosingBrace,
+        })
       }
     }
   }
@@ -47,7 +47,7 @@ export function buildPackageCompletions(
       completions.packages.push({
         type: 'pkg',
         label: item,
-        apply: createRequiredParameterApplier(item),
+        extend: extendRequiredParameter,
       })
 
       const label = `\\usepackage{${item}}`
@@ -56,19 +56,19 @@ export function buildPackageCompletions(
       completions.commands.push({
         type: 'pkg',
         label,
-        apply: createCommandApplier(label),
+        extend: extendOverUnpairedClosingBrace,
       })
     }
   }
 
   // empty \\usepackage{…} snippet
-  completions.commands.push(
-    customSnippetCompletion('\\usepackage{#{}}', {
-      type: 'pkg',
-      label: '\\usepackage{}',
-      boost: 10,
-    })
-  )
+  completions.commands.push({
+    type: 'pkg',
+    label: '\\usepackage{}',
+    boost: 10,
+    apply: applySnippet('\\usepackage{#{}}'),
+    extend: extendOverUnpairedClosingBrace,
+  })
 }
 
 const findExistingPackageNames = (context: CompletionContext) => {

+ 2 - 2
services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts

@@ -4,7 +4,7 @@
 import { CompletionContext } from '@codemirror/autocomplete'
 import { Completions } from './types'
 import { metadataState } from '../../../extensions/language'
-import { createRequiredParameterApplier } from './apply'
+import { extendRequiredParameter } from './apply'
 
 export function buildReferenceCompletions(
   completions: Completions,
@@ -20,7 +20,7 @@ export function buildReferenceCompletions(
     completions.references.push({
       type: 'reference',
       label: reference,
-      apply: createRequiredParameterApplier(reference),
+      extend: extendRequiredParameter,
     })
   }
 }

+ 14 - 14
services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts

@@ -1,6 +1,6 @@
 import topHundredSnippets from './data/top-hundred-snippets'
 import snippets from './data/snippets'
-import { customSnippetCompletion } from './apply'
+import { applySnippet, extendOverUnpairedClosingBrace } from './apply'
 import { Completions } from './types'
 
 /**
@@ -8,21 +8,21 @@ import { Completions } from './types'
  */
 export function buildSnippetCompletions(completions: Completions) {
   for (const item of topHundredSnippets) {
-    completions.commands.push(
-      customSnippetCompletion(item.snippet, {
-        type: item.meta,
-        label: item.caption,
-        boost: item.score,
-      })
-    )
+    completions.commands.push({
+      type: item.meta,
+      label: item.caption,
+      boost: item.score,
+      apply: applySnippet(item.snippet),
+      extend: extendOverUnpairedClosingBrace,
+    })
   }
 
   for (const item of snippets) {
-    completions.commands.push(
-      customSnippetCompletion(item.snippet, {
-        type: item.type,
-        label: item.label,
-      })
-    )
+    completions.commands.push({
+      type: item.type,
+      label: item.label,
+      apply: applySnippet(item.snippet),
+      extend: extendOverUnpairedClosingBrace,
+    })
   }
 }

+ 4 - 1
services/web/frontend/js/features/source-editor/languages/latex/snippets.ts

@@ -1,4 +1,7 @@
-// Add a final placeholder at the end of the snippet to allow for
+// 1. Convert from Ace `$1` to CodeMirror numbered placeholder format `${1}` or `#{1}` in snippets.
+// Note: metadata from the server still uses the old format, so it's not enough to convert all
+// the bundled data to the new format.
+// 2. Add a final placeholder at the end of the snippet to allow for
 // shift-tabbing back from the penultimate placeholder. See #8697.
 export const prepareSnippetTemplate = (template: string): string => {
   return template.replace(/\$(\d+)/g, '#{$1}') + '${}'

+ 1 - 1
services/web/package.json

@@ -71,7 +71,7 @@
     "@babel/preset-env": "^7.14.5",
     "@babel/preset-react": "^7.14.5",
     "@babel/preset-typescript": "^7.16.0",
-    "@codemirror/autocomplete": "^6.5.1",
+    "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#cd13c2c15a6f89b207f7cc4523e38fd5ef0efc47",
     "@codemirror/commands": "^6.2.3",
     "@codemirror/lang-markdown": "^6.1.1",
     "@codemirror/language": "^6.6.0",

+ 11 - 11
services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx

@@ -1003,46 +1003,46 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     cy.get('@line').type('\\include{e', { delay: 100 })
     cy.findAllByRole('option').contains('example.tex').click()
     activeEditorLine().contains('\\include{example')
-    activeEditorLine().type('{}}{Enter}')
+    activeEditorLine().type('}{Enter}')
 
     activeEditorLine().type('\\include{s', { delay: 100 })
     cy.findAllByRole('option').contains('sometext.txt').click()
-    activeEditorLine().contains('\\include{sometext.txt')
-    activeEditorLine().type('{}}{Enter}')
+    activeEditorLine().should('have.text', '\\include{sometext.txt}')
+    activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().type('\\inclu', { delay: 100 })
     cy.contains('\\include{}').click()
     cy.contains('example.tex').click()
-    activeEditorLine().contains('\\include{example}')
+    activeEditorLine().should('have.text', '\\include{example}')
     activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().type('\\inclu', { delay: 100 })
     cy.findAllByRole('option').contains('\\include{}').click()
     cy.findAllByRole('option').contains('sometext.txt').click()
-    activeEditorLine().contains('\\include{sometext.txt}')
+    activeEditorLine().should('have.text', '\\include{sometext.txt}')
     activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().click().as('line')
     activeEditorLine().type('\\input{e', { delay: 100 })
     cy.findAllByRole('option').contains('example.tex').click()
-    activeEditorLine().contains('\\input{example')
-    activeEditorLine().type('{}}{Enter}')
+    activeEditorLine().should('have.text', '\\input{example}')
+    activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().click().as('line')
     activeEditorLine().type('\\input{s', { delay: 100 })
     cy.findAllByRole('option').contains('sometext.txt').click()
-    activeEditorLine().contains('\\input{sometext.txt')
-    activeEditorLine().type('{}}{Enter}')
+    activeEditorLine().should('have.text', '\\input{sometext.txt}')
+    activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().type('\\inpu', { delay: 100 })
     cy.findAllByRole('option').contains('\\input{}').click()
     cy.findAllByRole('option').contains('example.tex').click()
-    activeEditorLine().contains('\\input{example}')
+    activeEditorLine().should('have.text', '\\input{example}')
     activeEditorLine().type('{rightArrow}{Enter}')
 
     activeEditorLine().type('\\inpu', { delay: 100 })
     cy.findAllByRole('option').contains('\\input{}').click()
     cy.findAllByRole('option').contains('sometext.txt').click()
-    activeEditorLine().contains('\\input{sometext.txt}')
+    activeEditorLine().should('have.text', '\\input{sometext.txt}')
   })
 })

+ 3 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx

@@ -3,6 +3,8 @@ import { EditorProviders } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { mockScope } from '../helpers/mock-scope'
 
+const isMac = /Mac/.test(window.navigator?.platform)
+
 const Container: FC = ({ children }) => (
   <div style={{ width: 785, height: 785 }}>{children}</div>
 )
@@ -110,7 +112,7 @@ ${'long line '.repeat(200)}`
   it('has no active line highlight when there is a selection', function () {
     // Put the cursor on a blank line
     cy.get('.cm-line').eq(1).click().as('line')
-    cy.get('@line').type('{ctrl}A')
+    cy.get('@line').type(isMac ? '{cmd}A' : '{ctrl}A')
 
     cy.get('.cm-activeLine').should('not.exist')
   })