overleaf/services/web/frontend/js/features/source-editor/extensions/close-brackets.ts

482 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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
}