From 0ed42ce6ad4d9c45ca49e29dfb99abd2a729b0d5 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 14 Apr 2023 09:57:19 +0100 Subject: [PATCH] [cm6] Use a modified fork of the closeBrackets extension (#12573) GitOrigin-RevId: a24020ed216cb10defff989f5876666c29889de2 --- .../source-editor/extensions/auto-pair.ts | 4 +- .../extensions/close-brackets.ts | 481 ++++++++++++++++++ .../extensions/close-prefixed-brackets.ts | 206 -------- .../languages/latex/close-bracket-config.ts | 123 +++++ .../languages/latex/latex-language.ts | 15 +- .../codemirror-editor-close-brackets.spec.tsx | 192 +++++++ .../components/codemirror-editor.spec.tsx | 133 ----- 7 files changed, 799 insertions(+), 355 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/close-brackets.ts delete mode 100644 services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts create mode 100644 services/web/frontend/js/features/source-editor/languages/latex/close-bracket-config.ts create mode 100644 services/web/test/frontend/features/source-editor/components/codemirror-editor-close-brackets.spec.tsx 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 446f5e9df8..e6fa9b2494 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,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)), 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 new file mode 100644 index 0000000000..203f907eac --- /dev/null +++ b/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 = { + 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/extensions/close-prefixed-brackets.ts b/services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts deleted file mode 100644 index 00199f286e..0000000000 --- a/services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts +++ /dev/null @@ -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 - }>('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 -} 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 new file mode 100644 index 0000000000..4e7353d064 --- /dev/null +++ b/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 +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts index 53b01020fb..50745b8ddf 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts +++ b/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 = { 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, }, }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-close-brackets.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-close-brackets.spec.tsx new file mode 100644 index 0000000000..ca7ac2682a --- /dev/null +++ b/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 }) => ( +
{children}
+) + +describe('close brackets', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + const scope = mockScope() + + cy.mount( + + + + + + ) + + 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]') + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx index 96bf225b63..a47de12687 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx @@ -445,139 +445,6 @@ describe('', { scrollBehavior: false }, function () { cy.findByRole('button', { name: 'Close' }).click() }) - it('auto-closes custom brackets', function () { - const scope = mockScope() - - cy.mount( - - - - - - ) - - // 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()