mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-20 11:03:40 +00:00
[cm6] Use a modified fork of the closeBrackets extension (#12573)
GitOrigin-RevId: a24020ed216cb10defff989f5876666c29889de2
This commit is contained in:
parent
dda9230c6c
commit
359255b820
7 changed files with 799 additions and 355 deletions
|
@ -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)),
|
||||
|
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* This file is adapted from CodeMirror 6, licensed under the MIT license:
|
||||
* https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
|
||||
*/
|
||||
|
||||
import { EditorView, KeyBinding } from '@codemirror/view'
|
||||
import {
|
||||
EditorState,
|
||||
EditorSelection,
|
||||
Transaction,
|
||||
Extension,
|
||||
StateCommand,
|
||||
StateField,
|
||||
StateEffect,
|
||||
MapMode,
|
||||
CharCategory,
|
||||
Text,
|
||||
codePointAt,
|
||||
fromCodePoint,
|
||||
codePointSize,
|
||||
RangeSet,
|
||||
RangeValue,
|
||||
SelectionRange,
|
||||
} from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
|
||||
/// Configures bracket closing behavior for a syntax (via
|
||||
/// [language data](#state.EditorState.languageDataAt)) using the `"closeBrackets"`
|
||||
/// identifier.
|
||||
export interface CloseBracketConfig {
|
||||
/// The opening and closing tokens.
|
||||
brackets?: string[]
|
||||
/// Characters in front of which newly opened brackets are
|
||||
/// automatically closed. Closing always happens in front of
|
||||
/// whitespace. Defaults to `")]}:;>"`.
|
||||
before?: string
|
||||
/// When determining whether a given node may be a string, recognize
|
||||
/// these prefixes before the opening quote.
|
||||
stringPrefixes?: string[]
|
||||
/// An optional callback for overriding the content that's inserted
|
||||
/// based on surrounding characters
|
||||
// added by Overleaf
|
||||
buildInsert?: (
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
open: string,
|
||||
close: string
|
||||
) => string
|
||||
}
|
||||
|
||||
const defaults: Required<CloseBracketConfig> = {
|
||||
brackets: ['(', '[', '{', "'", '"'],
|
||||
before: ')]}:;>',
|
||||
stringPrefixes: [],
|
||||
// added by Overleaf
|
||||
buildInsert: (state, range, open, close) => open + close,
|
||||
}
|
||||
|
||||
const closeBracketEffect = StateEffect.define<number>({
|
||||
map(value, mapping) {
|
||||
const mapped = mapping.mapPos(value, -1, MapMode.TrackAfter)
|
||||
return mapped === null ? undefined : mapped
|
||||
},
|
||||
})
|
||||
|
||||
const closedBracket = new (class extends RangeValue {})()
|
||||
closedBracket.startSide = 1
|
||||
closedBracket.endSide = -1
|
||||
|
||||
const bracketState = StateField.define<RangeSet<typeof closedBracket>>({
|
||||
create() {
|
||||
return RangeSet.empty
|
||||
},
|
||||
update(value, tr) {
|
||||
if (tr.selection) {
|
||||
const lineStart = tr.state.doc.lineAt(tr.selection.main.head).from
|
||||
const prevLineStart = tr.startState.doc.lineAt(
|
||||
tr.startState.selection.main.head
|
||||
).from
|
||||
if (lineStart !== tr.changes.mapPos(prevLineStart, -1))
|
||||
value = RangeSet.empty
|
||||
}
|
||||
value = value.map(tr.changes)
|
||||
for (const effect of tr.effects)
|
||||
if (effect.is(closeBracketEffect))
|
||||
value = value.update({
|
||||
add: [closedBracket.range(effect.value, effect.value + 1)],
|
||||
})
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
/// Extension to enable bracket-closing behavior. When a closeable
|
||||
/// bracket is typed, its closing bracket is immediately inserted
|
||||
/// after the cursor. When closing a bracket directly in front of a
|
||||
/// closing bracket inserted by the extension, the cursor moves over
|
||||
/// that bracket.
|
||||
export function closeBrackets(): Extension {
|
||||
return [inputHandler, bracketState]
|
||||
}
|
||||
|
||||
const definedClosing = '()[]{}<>'
|
||||
|
||||
function closing(ch: number) {
|
||||
for (let i = 0; i < definedClosing.length; i += 2)
|
||||
if (definedClosing.charCodeAt(i) === ch) return definedClosing.charAt(i + 1)
|
||||
return fromCodePoint(ch < 128 ? ch : ch + 1)
|
||||
}
|
||||
|
||||
function config(state: EditorState, pos: number) {
|
||||
return (
|
||||
state.languageDataAt<CloseBracketConfig>('closeBrackets', pos)[0] ||
|
||||
defaults
|
||||
)
|
||||
}
|
||||
|
||||
const android =
|
||||
typeof navigator === 'object' && /Android\b/.test(navigator.userAgent)
|
||||
|
||||
const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
if (
|
||||
(android ? view.composing : view.compositionStarted) ||
|
||||
view.state.readOnly
|
||||
)
|
||||
return false
|
||||
const sel = view.state.selection.main
|
||||
if (
|
||||
insert.length > 2 ||
|
||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
||||
from !== sel.from ||
|
||||
to !== sel.to
|
||||
)
|
||||
return false
|
||||
const tr = insertBracket(view.state, insert)
|
||||
if (!tr) return false
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
})
|
||||
|
||||
/// Command that implements deleting a pair of matching brackets when
|
||||
/// the cursor is between them.
|
||||
export const deleteBracketPair: StateCommand = ({ state, dispatch }) => {
|
||||
if (state.readOnly) return false
|
||||
const conf = config(state, state.selection.main.head)
|
||||
const tokens = conf.brackets || defaults.brackets
|
||||
let dont = null
|
||||
const changes = state.changeByRange(range => {
|
||||
if (range.empty) {
|
||||
const before = prevChar(state.doc, range.head)
|
||||
for (const token of tokens) {
|
||||
if (
|
||||
token === before &&
|
||||
nextChar(state.doc, range.head) === closing(codePointAt(token, 0))
|
||||
)
|
||||
return {
|
||||
changes: {
|
||||
from: range.head - token.length,
|
||||
to: range.head + token.length,
|
||||
},
|
||||
range: EditorSelection.cursor(range.head - token.length),
|
||||
}
|
||||
}
|
||||
}
|
||||
return { range: (dont = range) }
|
||||
})
|
||||
if (!dont)
|
||||
dispatch(
|
||||
state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'delete.backward',
|
||||
})
|
||||
)
|
||||
return !dont
|
||||
}
|
||||
|
||||
/// Close-brackets related key bindings. Binds Backspace to
|
||||
/// [`deleteBracketPair`](#autocomplete.deleteBracketPair).
|
||||
export const closeBracketsKeymap: readonly KeyBinding[] = [
|
||||
{ key: 'Backspace', run: deleteBracketPair },
|
||||
]
|
||||
|
||||
/// Implements the extension's behavior on text insertion. If the
|
||||
/// given string counts as a bracket in the language around the
|
||||
/// selection, and replacing the selection with it requires custom
|
||||
/// behavior (inserting a closing version or skipping past a
|
||||
/// previously-closed bracket), this function returns a transaction
|
||||
/// representing that custom behavior. (You only need this if you want
|
||||
/// to programmatically insert brackets—the
|
||||
/// [`closeBrackets`](#autocomplete.closeBrackets) extension will
|
||||
/// take care of running this for user input.)
|
||||
export function insertBracket(
|
||||
state: EditorState,
|
||||
bracket: string
|
||||
): Transaction | null {
|
||||
const conf = config(state, state.selection.main.head)
|
||||
const tokens = conf.brackets || defaults.brackets
|
||||
for (const tok of tokens) {
|
||||
const closed = closing(codePointAt(tok, 0))
|
||||
if (bracket === tok)
|
||||
return closed === tok
|
||||
? handleSame(
|
||||
state,
|
||||
tok,
|
||||
tokens.indexOf(tok + tok) > -1,
|
||||
tokens.indexOf(tok + tok + tok) > -1,
|
||||
conf
|
||||
)
|
||||
: handleOpen(state, tok, closed, conf.before || defaults.before, conf)
|
||||
|
||||
if (bracket === closed && closedBracketAt(state, state.selection.main.from))
|
||||
return handleClose(state, tok, closed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function closedBracketAt(state: EditorState, pos: number) {
|
||||
let found = false
|
||||
state.field(bracketState).between(0, state.doc.length, from => {
|
||||
if (from === pos) found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function nextChar(doc: Text, pos: number) {
|
||||
const next = doc.sliceString(pos, pos + 2)
|
||||
return next.slice(0, codePointSize(codePointAt(next, 0)))
|
||||
}
|
||||
|
||||
function prevChar(doc: Text, pos: number) {
|
||||
const prev = doc.sliceString(pos - 2, pos)
|
||||
return codePointSize(codePointAt(prev, 0)) === prev.length
|
||||
? prev
|
||||
: prev.slice(1)
|
||||
}
|
||||
|
||||
function handleOpen(
|
||||
state: EditorState,
|
||||
open: string,
|
||||
close: string,
|
||||
closeBefore: string,
|
||||
config: CloseBracketConfig
|
||||
) {
|
||||
// added by Overleaf
|
||||
const buildInsert = config.buildInsert || defaults.buildInsert
|
||||
|
||||
let dont = null
|
||||
const changes = state.changeByRange(range => {
|
||||
if (!range.empty)
|
||||
return {
|
||||
changes: [
|
||||
{ insert: open, from: range.from },
|
||||
{ insert: close, from: range.to },
|
||||
],
|
||||
effects: closeBracketEffect.of(range.to + open.length),
|
||||
range: EditorSelection.range(
|
||||
range.anchor + open.length,
|
||||
range.head + open.length
|
||||
),
|
||||
}
|
||||
const next = nextChar(state.doc, range.head)
|
||||
if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
|
||||
// added by Overleaf
|
||||
const insert = buildInsert(state, range, open, close) ?? open + close
|
||||
|
||||
return {
|
||||
changes: { insert, from: range.head },
|
||||
effects:
|
||||
// modified by Overleaf
|
||||
insert === open
|
||||
? []
|
||||
: closeBracketEffect.of(range.head + open.length),
|
||||
range: EditorSelection.cursor(range.head + open.length),
|
||||
}
|
||||
}
|
||||
return { range: (dont = range) }
|
||||
})
|
||||
return dont
|
||||
? null
|
||||
: state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'input.type',
|
||||
})
|
||||
}
|
||||
|
||||
function handleClose(state: EditorState, _open: string, close: string) {
|
||||
let dont = null
|
||||
const changes = state.changeByRange(range => {
|
||||
if (range.empty && nextChar(state.doc, range.head) === close)
|
||||
return {
|
||||
changes: {
|
||||
from: range.head,
|
||||
to: range.head + close.length,
|
||||
insert: close,
|
||||
},
|
||||
range: EditorSelection.cursor(range.head + close.length),
|
||||
}
|
||||
return (dont = { range })
|
||||
})
|
||||
return dont
|
||||
? null
|
||||
: state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'input.type',
|
||||
})
|
||||
}
|
||||
|
||||
// Handles cases where the open and close token are the same, and
|
||||
// possibly triple quotes (as in `"""abc"""`-style quoting).
|
||||
function handleSame(
|
||||
state: EditorState,
|
||||
token: string,
|
||||
// added by Overleaf
|
||||
allowDouble: boolean,
|
||||
allowTriple: boolean,
|
||||
config: CloseBracketConfig
|
||||
) {
|
||||
const stringPrefixes = config.stringPrefixes || defaults.stringPrefixes
|
||||
// added by Overleaf
|
||||
const buildInsert = config.buildInsert || defaults.buildInsert
|
||||
|
||||
let dont = null
|
||||
const changes = state.changeByRange(range => {
|
||||
if (!range.empty)
|
||||
return {
|
||||
changes: [
|
||||
{ insert: token, from: range.from },
|
||||
{ insert: token, from: range.to },
|
||||
],
|
||||
effects: closeBracketEffect.of(range.to + token.length),
|
||||
range: EditorSelection.range(
|
||||
range.anchor + token.length,
|
||||
range.head + token.length
|
||||
),
|
||||
}
|
||||
const pos = range.head
|
||||
const next = nextChar(state.doc, pos)
|
||||
|
||||
let start
|
||||
if (
|
||||
allowTriple &&
|
||||
state.sliceDoc(pos - 2 * token.length, pos) === token + token &&
|
||||
(start = canStartStringAt(
|
||||
state,
|
||||
pos - 2 * token.length,
|
||||
stringPrefixes
|
||||
)) > -1 &&
|
||||
nodeStart(state, start)
|
||||
) {
|
||||
return {
|
||||
changes: { insert: token + token + token + token, from: pos },
|
||||
effects: closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length),
|
||||
}
|
||||
} else if (
|
||||
// added by Overleaf, for $$
|
||||
allowDouble &&
|
||||
state.sliceDoc(pos - token.length, pos) === token &&
|
||||
(start = canStartStringAt(state, pos - token.length, stringPrefixes)) >
|
||||
-1 &&
|
||||
nodeStart(state, start)
|
||||
) {
|
||||
// added by Overleaf
|
||||
const insert = buildInsert(state, range, token, token) ?? token + token
|
||||
|
||||
return {
|
||||
changes: { insert, from: pos },
|
||||
effects:
|
||||
// modified by Overleaf
|
||||
insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length),
|
||||
}
|
||||
} else if (next === token) {
|
||||
if (nodeStart(state, pos)) {
|
||||
// added by Overleaf
|
||||
const insert = buildInsert(state, range, token, token) ?? token + token
|
||||
|
||||
return {
|
||||
changes: { insert, from: pos },
|
||||
effects:
|
||||
// modified by Overleaf
|
||||
insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length),
|
||||
}
|
||||
} else if (closedBracketAt(state, pos)) {
|
||||
const isTriple =
|
||||
allowTriple &&
|
||||
state.sliceDoc(pos, pos + token.length * 3) === token + token + token
|
||||
const content = isTriple ? token + token + token : token
|
||||
return {
|
||||
changes: { from: pos, to: pos + content.length, insert: content },
|
||||
range: EditorSelection.cursor(pos + content.length),
|
||||
}
|
||||
}
|
||||
} else if (state.charCategorizer(pos)(next) !== CharCategory.Word) {
|
||||
if (
|
||||
canStartStringAt(state, pos, stringPrefixes) > -1 &&
|
||||
!probablyInString(state, pos, token, stringPrefixes)
|
||||
) {
|
||||
// added by Overleaf
|
||||
const insert = buildInsert(state, range, token, token) ?? token + token
|
||||
|
||||
return {
|
||||
changes: { insert, from: pos },
|
||||
effects:
|
||||
// modified by Overleaf
|
||||
insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length),
|
||||
}
|
||||
}
|
||||
}
|
||||
return { range: (dont = range) }
|
||||
})
|
||||
return dont
|
||||
? null
|
||||
: state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'input.type',
|
||||
})
|
||||
}
|
||||
|
||||
function nodeStart(state: EditorState, pos: number) {
|
||||
const tree = syntaxTree(state).resolveInner(pos + 1)
|
||||
return tree.parent && tree.from === pos
|
||||
}
|
||||
|
||||
function probablyInString(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
quoteToken: string,
|
||||
prefixes: readonly string[]
|
||||
) {
|
||||
let node = syntaxTree(state).resolveInner(pos, -1)
|
||||
const maxPrefix = prefixes.reduce((m, p) => Math.max(m, p.length), 0)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = state.sliceDoc(
|
||||
node.from,
|
||||
Math.min(node.to, node.from + quoteToken.length + maxPrefix)
|
||||
)
|
||||
const quotePos = start.indexOf(quoteToken)
|
||||
if (
|
||||
!quotePos ||
|
||||
(quotePos > -1 && prefixes.indexOf(start.slice(0, quotePos)) > -1)
|
||||
) {
|
||||
let first = node.firstChild
|
||||
while (
|
||||
first &&
|
||||
first.from === node.from &&
|
||||
first.to - first.from > quoteToken.length + quotePos
|
||||
) {
|
||||
if (
|
||||
state.sliceDoc(first.to - quoteToken.length, first.to) === quoteToken
|
||||
)
|
||||
return false
|
||||
first = first.firstChild
|
||||
}
|
||||
return true
|
||||
}
|
||||
const parent = node.to === pos && node.parent
|
||||
if (!parent) break
|
||||
node = parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function canStartStringAt(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
prefixes: readonly string[]
|
||||
) {
|
||||
const charCat = state.charCategorizer(pos)
|
||||
if (charCat(state.sliceDoc(pos - 1, pos)) !== CharCategory.Word) return pos
|
||||
for (const prefix of prefixes) {
|
||||
const start = pos - prefix.length
|
||||
if (
|
||||
state.sliceDoc(start, pos) === prefix &&
|
||||
charCat(state.sliceDoc(start - 1, start)) !== CharCategory.Word
|
||||
)
|
||||
return start
|
||||
}
|
||||
return -1
|
||||
}
|
|
@ -1,206 +0,0 @@
|
|||
/**
|
||||
* This file is adapted from CodeMirror 6, licensed under the MIT license:
|
||||
* https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
|
||||
*/
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import {
|
||||
codePointAt,
|
||||
codePointSize,
|
||||
EditorSelection,
|
||||
Extension,
|
||||
SelectionRange,
|
||||
Text,
|
||||
TransactionSpec,
|
||||
} from '@codemirror/state'
|
||||
import { nextChar, prevChar } from '../languages/latex/completions/apply'
|
||||
import { completionStatus } from '@codemirror/autocomplete'
|
||||
import { ancestorNodeOfType } from '../utils/tree-query'
|
||||
import browser from './browser'
|
||||
|
||||
const dispatchInput = (view: EditorView, spec: TransactionSpec) => {
|
||||
// This is consistent with CM6's closebrackets extension and allows other
|
||||
// extensions that check for user input to be triggered
|
||||
view.dispatch(spec, {
|
||||
scrollIntoView: true,
|
||||
userEvent: 'input.type',
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const insertInput = (view: EditorView, insert: string) => {
|
||||
const spec = view.state.changeByRange(range => {
|
||||
return {
|
||||
changes: [[{ from: range.from, insert }]],
|
||||
range: EditorSelection.range(range.from + 1, range.to + 1),
|
||||
}
|
||||
})
|
||||
|
||||
return dispatchInput(view, spec)
|
||||
}
|
||||
|
||||
const insertBracket = (view: EditorView, open: string, close: string) => {
|
||||
const spec = view.state.changeByRange(range => {
|
||||
if (range.empty) {
|
||||
return {
|
||||
changes: [{ from: range.head, insert: open + close }],
|
||||
range: EditorSelection.cursor(range.head + open.length),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
changes: [
|
||||
{ from: range.from, insert: open },
|
||||
{ from: range.to, insert: close },
|
||||
],
|
||||
range: EditorSelection.range(
|
||||
range.anchor + open.length,
|
||||
range.head + open.length
|
||||
),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return dispatchInput(view, spec)
|
||||
}
|
||||
|
||||
export const closePrefixedBrackets = (): Extension => {
|
||||
return EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
if (
|
||||
(browser.android ? view.composing : view.compositionStarted) ||
|
||||
view.state.readOnly
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// avoid auto-closing curly braces when autocomplete is open
|
||||
if (insert === '{' && completionStatus(view.state)) {
|
||||
return insertInput(view, insert)
|
||||
}
|
||||
|
||||
const { doc, selection } = view.state
|
||||
|
||||
const sel = selection.main
|
||||
|
||||
if (
|
||||
from !== sel.from ||
|
||||
to !== sel.to ||
|
||||
insert.length > 2 ||
|
||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [config] = view.state.languageDataAt<{
|
||||
brackets?: Record<string, string | false>
|
||||
}>('closePrefixedBrackets', sel.head)
|
||||
|
||||
// no config for this language, don't handle
|
||||
if (!config?.brackets) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prevCharacter = prevChar(view.state.doc, sel.from)
|
||||
const input = `${prevCharacter}${insert}`
|
||||
const close = config.brackets[input] ?? config.brackets[insert]
|
||||
|
||||
// not specified, don't handle
|
||||
if (close === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// prevent auto-close, just insert the character
|
||||
if (close === false) {
|
||||
return insertInput(view, insert)
|
||||
}
|
||||
|
||||
const nextCharacter = nextChar(doc, sel.from)
|
||||
|
||||
if (insert === '$') {
|
||||
// avoid duplicating a math-closing dollar sign
|
||||
if (moveOverClosingMathDollar(view, sel)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// avoid creating an odd number of dollar signs
|
||||
const count = countSurroundingCharacters(doc, sel.from, insert)
|
||||
if (count % 2 !== 0) {
|
||||
return insertInput(view, insert)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the default set of "before" characters from the closeBrackets extension,
|
||||
// plus $ (so $$ works as expected)
|
||||
if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) {
|
||||
// auto-close
|
||||
return insertBracket(view, insert, close)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const moveOverClosingMathDollar = (
|
||||
view: EditorView,
|
||||
sel: SelectionRange
|
||||
): boolean => {
|
||||
if (!sel.empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
// inside dollar math
|
||||
const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath')
|
||||
if (!outerNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
// not display math
|
||||
const innerNode = outerNode.getChild('InlineMath')
|
||||
if (!innerNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
// the cursor is at the end of the InlineMath node
|
||||
if (sel.from !== innerNode.to) {
|
||||
return false
|
||||
}
|
||||
|
||||
// there's already some math content
|
||||
const content = view.state.doc.sliceString(innerNode.from, innerNode.to)
|
||||
if (content.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// move the cursor outside the DollarMath node
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(outerNode.to),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => {
|
||||
let count = 0
|
||||
|
||||
// count backwards
|
||||
let to = pos
|
||||
do {
|
||||
const char = doc.sliceString(to - 1, to)
|
||||
if (char !== insert) {
|
||||
break
|
||||
}
|
||||
count++
|
||||
to--
|
||||
} while (to > 1)
|
||||
|
||||
// count forwards
|
||||
let from = pos
|
||||
do {
|
||||
const char = doc.sliceString(from, from + 1)
|
||||
if (char !== insert) {
|
||||
break
|
||||
}
|
||||
count++
|
||||
from++
|
||||
} while (from < doc.length)
|
||||
|
||||
return count
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -9,6 +9,7 @@ import {
|
|||
findClosingFoldComment,
|
||||
getFoldRange,
|
||||
} from '../../utils/tree-query'
|
||||
import { closeBracketConfig } from './close-bracket-config'
|
||||
|
||||
const styleOverrides: Record<string, any> = {
|
||||
DocumentClassCtrlSeq: t.keyword,
|
||||
|
@ -173,18 +174,6 @@ export const LaTeXLanguage = LRLanguage.define({
|
|||
}),
|
||||
languageData: {
|
||||
commentTokens: { line: '%' },
|
||||
closeBrackets: { brackets: ['[', '{'] },
|
||||
closePrefixedBrackets: {
|
||||
brackets: {
|
||||
// $$ will produce $$ $$, but we set a single closing $ sign as the value
|
||||
// because inserting $ will already have added a closing bracket.
|
||||
$$: '$',
|
||||
$: '$',
|
||||
'\\(': '\\)',
|
||||
'\\[': '\\]',
|
||||
'\\$': false,
|
||||
'\\{': false,
|
||||
},
|
||||
},
|
||||
closeBrackets: closeBracketConfig,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
import { FC } from 'react'
|
||||
import { mockScope } from '../helpers/mock-scope'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||
|
||||
const Container: FC = ({ children }) => (
|
||||
<div style={{ width: 785, height: 785 }}>{children}</div>
|
||||
)
|
||||
|
||||
describe('close brackets', { scrollBehavior: false }, function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
cy.interceptEvents()
|
||||
cy.interceptSpelling()
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(20).click().as('active-line')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
})
|
||||
|
||||
describe('unprefixed characters', function () {
|
||||
it('auto-closes a curly bracket', function () {
|
||||
cy.get('@active-line')
|
||||
.type('{{}')
|
||||
.should('have.text', '{}')
|
||||
.type('{backspace}')
|
||||
.should('have.text', '')
|
||||
})
|
||||
|
||||
it('auto-closes a square bracket', function () {
|
||||
cy.get('@active-line')
|
||||
.type('[')
|
||||
.should('have.text', '[]')
|
||||
.type('{backspace}')
|
||||
.should('have.text', '')
|
||||
})
|
||||
|
||||
it('does not auto-close a round bracket', function () {
|
||||
cy.get('@active-line').type('(').should('have.text', '(')
|
||||
})
|
||||
|
||||
it('auto-closes a dollar sign', function () {
|
||||
cy.get('@active-line')
|
||||
.type('$')
|
||||
.should('have.text', '$$')
|
||||
.type('{backspace}')
|
||||
.should('have.text', '')
|
||||
})
|
||||
|
||||
it('auto-closes another dollar sign', function () {
|
||||
cy.get('@active-line')
|
||||
.type('$$')
|
||||
.should('have.text', '$$$$')
|
||||
.type('{backspace}{backspace}')
|
||||
.should('have.text', '')
|
||||
})
|
||||
|
||||
it('avoids creating an odd number of adjacent dollar signs', function () {
|
||||
cy.get('@active-line')
|
||||
.type('$2')
|
||||
.should('have.text', '$2$')
|
||||
.type('{leftArrow}$')
|
||||
.should('have.text', '$$2$')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prefixed characters', function () {
|
||||
it('auto-closes a backslash-prefixed round bracket', function () {
|
||||
cy.get('@active-line').type('\\(').should('have.text', '\\(\\)')
|
||||
})
|
||||
|
||||
it('auto-closes a backslash-prefixed square bracket', function () {
|
||||
cy.get('@active-line').type('\\[').should('have.text', '\\[\\]')
|
||||
})
|
||||
|
||||
it('does not auto-close a backslash-prefixed curly bracket', function () {
|
||||
cy.get('@active-line').type('\\{{}').should('have.text', '\\{')
|
||||
})
|
||||
|
||||
it('does not auto-close a backslash-prefixed dollar sign', function () {
|
||||
cy.get('@active-line').type('\\$').should('have.text', '\\$')
|
||||
})
|
||||
})
|
||||
|
||||
describe('double-prefixed characters', function () {
|
||||
it('auto-closes a double-backslash-prefixed square bracket with a square bracket', function () {
|
||||
cy.get('@active-line').type('\\\\[').should('have.text', '\\\\[]')
|
||||
})
|
||||
|
||||
it('auto-closes a double-backslash-prefixed curly bracket with a curly bracket', function () {
|
||||
cy.get('@active-line').type('\\\\{').should('have.text', '\\\\{}')
|
||||
})
|
||||
|
||||
it('auto-closes a double-backslash-prefixed dollar sign with a dollar sign', function () {
|
||||
cy.get('@active-line').type('\\\\$').should('have.text', '\\\\$$')
|
||||
})
|
||||
|
||||
it('does not auto-close a double-backslash-prefixed round bracket', function () {
|
||||
cy.get('@active-line').type('\\\\(').should('have.text', '\\\\(')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjacent characters', function () {
|
||||
it('does auto-close a dollar sign before punctuation', function () {
|
||||
cy.get('@active-line')
|
||||
.type(':2')
|
||||
.type('{leftArrow}{leftArrow}$')
|
||||
.should('have.text', '$$:2')
|
||||
})
|
||||
|
||||
it('does auto-close a dollar sign after punctuation', function () {
|
||||
cy.get('@active-line').type('2:').type('$').should('have.text', '2:$$')
|
||||
})
|
||||
|
||||
it('does not auto-close a dollar sign before text', function () {
|
||||
cy.get('@active-line')
|
||||
.type('2')
|
||||
.type('{leftArrow}$')
|
||||
.should('have.text', '$2')
|
||||
})
|
||||
|
||||
it('does not auto-close a dollar sign after text', function () {
|
||||
cy.get('@active-line').type('2').type('$').should('have.text', '2$')
|
||||
})
|
||||
|
||||
it('does auto-close a curly bracket before punctuation', function () {
|
||||
cy.get('@active-line')
|
||||
.type(':2')
|
||||
.type('{leftArrow}{leftArrow}{{}')
|
||||
.should('have.text', '{}:2')
|
||||
})
|
||||
|
||||
it('does auto-close a curly bracket after punctuation', function () {
|
||||
cy.get('@active-line').type('2:').type('{{}').should('have.text', '2:{}')
|
||||
})
|
||||
|
||||
it('does not auto-close a curly bracket before text', function () {
|
||||
cy.get('@active-line')
|
||||
.type('2')
|
||||
.type('{leftArrow}{{}')
|
||||
.should('have.text', '{2')
|
||||
})
|
||||
|
||||
it('does auto-close a curly bracket after text', function () {
|
||||
cy.get('@active-line').type('2').type('{{}').should('have.text', '2{}')
|
||||
})
|
||||
|
||||
it('does auto-close $$ before punctuation', function () {
|
||||
cy.get('@active-line')
|
||||
.type(':2')
|
||||
.type('{leftArrow}{leftArrow}$$')
|
||||
.should('have.text', '$$$$:2')
|
||||
})
|
||||
|
||||
it('does not auto-close $$ before text', function () {
|
||||
cy.get('@active-line')
|
||||
.type('2')
|
||||
.type('{leftArrow}$$')
|
||||
.should('have.text', '$$2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closed brackets', function () {
|
||||
it('does type over a closing dollar sign', function () {
|
||||
cy.get('@active-line').type('$2$').should('have.text', '$2$')
|
||||
})
|
||||
|
||||
it('does type over two closing dollar signs', function () {
|
||||
cy.get('@active-line').type('$$2$$').should('have.text', '$$2$$')
|
||||
})
|
||||
|
||||
it('does type over a closing curly bracket', function () {
|
||||
cy.get('@active-line').type('{{}2}').should('have.text', '{2}')
|
||||
})
|
||||
|
||||
it('does type over a closing square bracket', function () {
|
||||
cy.get('@active-line').type('[2]').should('have.text', '[2]')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -445,139 +445,6 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
|
|||
cy.findByRole('button', { name: 'Close' }).click()
|
||||
})
|
||||
|
||||
it('auto-closes custom brackets', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<Container>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</Container>
|
||||
)
|
||||
|
||||
// put the cursor on a blank line to type in
|
||||
cy.get('.cm-line').eq(16).click().as('line')
|
||||
|
||||
// { auto-closes
|
||||
cy.get('@line').type('{{}') // NOTE: {{} = literal {
|
||||
cy.get('@line').should('have.text', '{}')
|
||||
cy.get('@line').type('{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// [ auto-closes
|
||||
cy.get('@line').type('[')
|
||||
cy.get('@line').should('have.text', '[]')
|
||||
cy.get('@line').type('{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $ auto-closes
|
||||
cy.get('@line').type('$')
|
||||
cy.get('@line').should('have.text', '$$')
|
||||
cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $$ auto-closes
|
||||
cy.get('@line').type('$$')
|
||||
cy.get('@line').should('have.text', '$$$$')
|
||||
cy.get('@line').type('{rightArrow}{rightArrow}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '$$')
|
||||
cy.get('@line').type('{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// \{ doesn't auto-close
|
||||
cy.get('@line').type('\\{{}')
|
||||
cy.get('@line').should('have.text', '\\{')
|
||||
cy.get('@line').type('{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// \[ *does* auto-close
|
||||
cy.get('@line').type('\\[')
|
||||
cy.get('@line').should('have.text', '\\[\\]')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// \( *does* auto-close
|
||||
cy.get('@line').type('\\(')
|
||||
cy.get('@line').should('have.text', '\\(\\)')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// \$ doesn't auto-close
|
||||
cy.get('@line').type('\\$')
|
||||
cy.get('@line').should('have.text', '\\$')
|
||||
cy.get('@line').type('{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// { doesn't auto-close in front of an alphanumeric character
|
||||
cy.get('@line').type('2{leftArrow}{{}')
|
||||
cy.get('@line').should('have.text', '{2')
|
||||
cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// [ doesn't auto-close in front of an alphanumeric character
|
||||
cy.get('@line').type('2{leftArrow}[')
|
||||
cy.get('@line').should('have.text', '[2')
|
||||
cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $ doesn't auto-close in front of an alphanumeric character
|
||||
cy.get('@line').type('2{leftArrow}$')
|
||||
cy.get('@line').should('have.text', '$2')
|
||||
cy.get('@line').type('{rightArrow}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $$ doesn't auto-close in front of an alphanumeric character
|
||||
cy.get('@line').type('2{leftArrow}$$')
|
||||
cy.get('@line').should('have.text', '$$2')
|
||||
cy.get('@line').type('{rightArrow}{Backspace}{Backspace}{Backspace}')
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// { does auto-close in front of a known character
|
||||
cy.get('@line').type(':{leftArrow}{{}')
|
||||
cy.get('@line').should('have.text', '{}:')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// [ does auto-close in front of a known character
|
||||
cy.get('@line').type(':{leftArrow}[')
|
||||
cy.get('@line').should('have.text', '[]:')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $ does auto-close in front of a known character
|
||||
cy.get('@line').type(':{leftArrow}$')
|
||||
cy.get('@line').should('have.text', '$$:')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $$ does auto-close in front of a known character
|
||||
cy.get('@line').type(':{leftArrow}$$')
|
||||
cy.get('@line').should('have.text', '$$$$:')
|
||||
cy.get('@line').type(
|
||||
'{rightArrow}{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}'
|
||||
)
|
||||
cy.get('@line').should('have.text', '')
|
||||
|
||||
// $ at the end of an inline "dollar math" node skips the closing $
|
||||
cy.get('@line').type('$2+3=5')
|
||||
cy.get('@line').should('have.text', '$2+3=5$')
|
||||
cy.get('@line').type('$')
|
||||
cy.get('@line').should('have.text', '$2+3=5$')
|
||||
cy.get('@line').type('{Backspace}'.repeat(7))
|
||||
cy.get('@line').should('have.text', '')
|
||||
})
|
||||
|
||||
it('navigates in the search panel', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue