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