diff --git a/package-lock.json b/package-lock.json index 8cefd92078..95273f8413 100644 --- a/package-lock.json +++ b/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", diff --git a/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts b/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts index e6fa9b2494..f3d8f0a2b2 100644 --- a/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts +++ b/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)), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/close-brackets.ts b/services/web/frontend/js/features/source-editor/extensions/close-brackets.ts deleted file mode 100644 index 203f907eac..0000000000 --- a/services/web/frontend/js/features/source-editor/extensions/close-brackets.ts +++ /dev/null @@ -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 = { - brackets: ['(', '[', '{', "'", '"'], - before: ')]}:;>', - stringPrefixes: [], - // added by Overleaf - buildInsert: (state, range, open, close) => open + close, -} - -const closeBracketEffect = StateEffect.define({ - 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>({ - 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('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 -} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts b/services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts index 4e7353d064..f993448c68 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts +++ b/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 diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts index 52efa6e05c..88efa89ddd 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts +++ b/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') - - // add a closing brace if needed - if (nextChar(doc, to) !== '}') { - if (countUnclosedBraces(doc, from, to) > 0) { - text += '}' - } - - 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] - - to += longestCommonPrefix(text.slice(to - from), restOfLine) - } - } - - view.dispatch(insertCompletionText(view.state, text, from, to)) - } - -/* - * 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 extendRequiredParameter = ( + 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++ - } + insert: string | Text + } +) => { + if (typeof change.insert !== 'string') { + return change + } + + const argumentNode = ancestorNodeOfType(state, change.from, '$Argument') + const isWellFormedArgumentNode = + argumentNode && + argumentNode.getChild('OpenBrace') && + argumentNode.getChild('CloseBrace') + + // add a closing brace if needed + if (nextChar(state.doc, change.to) !== '}') { + if (countUnclosedBraces(state.doc, change.from, change.to) > 0) { + change.insert += '}' } - const snippetApply = snippet(prepareSnippetTemplate(template)) - snippetApply(view, completion, from, to) - if (clear) { - clearSnippet(view) + 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 + ) } } + return change +} + +// extend forwards to cover an unpaired closing brace +export const extendOverUnpairedClosingBrace = ( + state: EditorState, + change: { + from: number + to: number + } +) => { + 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 -} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts index cf0c200e6d..36c2b25362 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts +++ b/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, }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts index a57c476fd4..f1a3e152ce 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts +++ b/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, }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts index d92f2ad555..03c4b4a92b 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts +++ b/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, + }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts index 61ace565f7..d1f9ce80b4 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts +++ b/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), }) } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts index 5b4bbddc34..0b5d4ad7d6 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts +++ b/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, }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts index 46a6a6145f..d1de93137a 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts +++ b/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, }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts index e17b7ab3a0..fc2541a006 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts +++ b/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) => { diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts index 7360e39d2f..147d3e86b8 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts +++ b/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, }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts index 4495f7591f..6555711dda 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts +++ b/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, + }) } } diff --git a/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts index 43be026905..729ce6cba3 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts +++ b/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}') + '${}' diff --git a/services/web/package.json b/services/web/package.json index 64d8553e41..3f6f0fb9c2 100644 --- a/services/web/package.json +++ b/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", diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index d2c4f305c9..b6a91fad2f 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/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}') }) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx index a144e9ff81..8fcc0f84c0 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx +++ b/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 }) => (
{children}
) @@ -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') })