| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762 |
- import { EditorView } from '@codemirror/view'
- import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
- import {
- ensureSyntaxTree,
- foldedRanges,
- foldEffect,
- syntaxTree,
- } from '@codemirror/language'
- import { SyntaxNode } from '@lezer/common'
- import { ancestorOfNodeWithType } from '../utils/tree-query'
- import { lastAncestorAtEndPosition } from '../utils/tree-operations/ancestors'
- import {
- formattingCommandMap,
- type FormattingCommand,
- type FormattingNodeType,
- } from '@/features/source-editor/utils/tree-operations/formatting'
- export const wrapRanges =
- (
- prefix: string,
- suffix: string,
- wrapWholeLine = false,
- selection?: (range: SelectionRange, view: EditorView) => SelectionRange
- ) =>
- (view: EditorView): boolean => {
- if (view.state.readOnly) {
- return false
- }
- view.dispatch(
- view.state.changeByRange(range => {
- const insert = { prefix, suffix }
- if (wrapWholeLine) {
- const line = view.state.doc.lineAt(range.anchor)
- if (range.empty) {
- // expand range to cover the whole line
- range = EditorSelection.range(line.from, line.to)
- }
- // add a newline at the start if needed
- if (range.from !== line.from) {
- insert.prefix = `\n${prefix}`
- }
- // add a newline at the end if needed
- if (range.to !== line.to) {
- insert.suffix = `${suffix}\n`
- }
- }
- const content = view.state.sliceDoc(range.from, range.to)
- // map through the prefix only
- const changedRange = range.map(
- view.state.changes([
- { from: range.from, insert: `${insert.prefix}` },
- ]),
- 1
- )
- return {
- range: selection ? selection(changedRange, view) : changedRange,
- // create a single change, including the content
- changes: [
- {
- from: range.from,
- to: range.to,
- insert: `${insert.prefix}${content}${insert.suffix}`,
- },
- ],
- }
- }),
- { scrollIntoView: true }
- )
- return true
- }
- export const changeCase =
- (upper = true) =>
- (view: EditorView) => {
- if (view.state.readOnly) {
- return false
- }
- view.dispatch(
- view.state.changeByRange(range => {
- // ignore empty ranges
- if (range.empty) {
- return { range }
- }
- const text = view.state.doc.sliceString(range.from, range.to)
- return {
- range,
- changes: [
- {
- from: range.from,
- to: range.to,
- insert: upper ? text.toUpperCase() : text.toLowerCase(),
- },
- ],
- }
- })
- )
- return true
- }
- export const duplicateSelection = (view: EditorView) => {
- if (view.state.readOnly) {
- return false
- }
- const foldedRangesInDocument = foldedRanges(view.state)
- view.dispatch(
- view.state.changeByRange(range => {
- const folds: { offset: number; base: number; length: number }[] = []
- if (range.empty) {
- const line = view.state.doc.lineAt(range.from)
- let lineStart = line.from
- let lineEnd = line.to
- // Calculate line start/end including folded ranges
- //
- // Note that at each iteration of the while loop, new folded ranges
- // can be included. This happens when there are multiple folded ranges
- // on a single editor line (but spanning multiple actual lines)
- //
- // For example, the following document:
- // 1: \begin{document}
- // 2: test
- // 3: \end{document}\begin{document}
- // 4: test
- // 5: \end{document}
- //
- // Can be folded to:
- // \begin{document}<...>\end{document}\begin{document}<...>\end{document}
- //
- // In this case, the first iterations of the while loop below will only
- // include lines 3-5, since the overlapping folded range for line 5
- // is only the fold on lines 3-5. Hence in the while loop, we expand the
- // selection until we include all the ranges.
- let changed
- do {
- changed = false
- foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
- const newLineStart = Math.min(
- view.state.doc.lineAt(from).from,
- lineStart
- )
- const newLineEnd = Math.max(view.state.doc.lineAt(to).to, lineEnd)
- if (newLineStart !== lineStart || newLineEnd !== lineEnd) {
- changed = true
- lineStart = newLineStart
- lineEnd = newLineEnd
- }
- })
- } while (changed)
- // Collect information needed to fold duplicated lines
- foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
- folds.push({
- offset: from - lineStart,
- base: lineEnd + view.state.lineBreak.length,
- length: to - from,
- })
- })
- // Duplicate the selected lines downwards
- return {
- range,
- changes: [
- {
- from: lineEnd,
- insert:
- view.state.lineBreak + view.state.doc.slice(lineStart, lineEnd),
- },
- ],
- // Add a fold effect for each fold in the original line
- effects: folds.map(fold =>
- foldEffect.of({
- from: fold.base + fold.offset,
- to: fold.base + fold.offset + fold.length,
- })
- ),
- }
- } else {
- // Duplicate selected text at head of selection
- let newSelectionRange = range
- if (range.head > range.anchor) {
- // Duplicating to the right, so we need to update the selected range
- newSelectionRange = EditorSelection.range(
- range.head,
- range.head + (range.to - range.from)
- )
- }
- return {
- // The new range is the duplicated section, placed at the head of the
- // original selection
- range: newSelectionRange,
- changes: [
- {
- from: range.head,
- insert: view.state.doc.slice(range.from, range.to),
- },
- ],
- }
- }
- })
- )
- return true
- }
- const skipParentNodeTypes = [
- 'LongArg',
- 'TextArgument',
- 'OpenBrace',
- 'CloseBrace',
- ]
- function getParentNode(
- position: number | SyntaxNode,
- state: EditorState,
- assoc: 0 | 1 | -1 = 1
- ): SyntaxNode | undefined {
- const tree = ensureSyntaxTree(state, 1000)
- let node: SyntaxNode | undefined | null
- if (typeof position === 'number') {
- node = tree?.resolveInner(position, assoc)?.parent
- const ancestorAtEndPos = lastAncestorAtEndPosition(node, position)
- if (ancestorAtEndPos?.parent) {
- node = ancestorAtEndPos.parent
- }
- } else {
- node = position?.parent
- }
- while (node && skipParentNodeTypes.includes(node.type.name)) {
- node = node.parent
- }
- return node || undefined
- }
- function wrapRangeInCommand(
- state: EditorState,
- range: SelectionRange,
- command: string
- ) {
- const content = state.sliceDoc(range.from, range.to)
- const changes = state.changes([
- {
- from: range.from,
- to: range.to,
- insert: `${command}{${content}}`,
- },
- ])
- return {
- changes,
- range: moveRange(
- range,
- range.from + command.length + 1,
- range.from + command.length + content.length + 1
- ),
- }
- }
- function moveRange(range: SelectionRange, newFrom: number, newTo: number) {
- const forwards = range.from === range.anchor
- return forwards
- ? EditorSelection.range(newFrom, newTo)
- : EditorSelection.range(newTo, newFrom)
- }
- function validateReplacement(expected: string, actual: string) {
- if (expected !== actual) {
- throw new Error('Replacement in toggleRange failed validation.')
- }
- }
- function getWrappingAncestor(
- node: SyntaxNode,
- nodeType: FormattingNodeType
- ): SyntaxNode | null {
- for (
- let ancestor: SyntaxNode | null = node;
- ancestor;
- ancestor = ancestor.parent
- ) {
- if (ancestor.type.is(nodeType)) {
- return ancestor
- }
- if (formattingCommandMap[ancestor.type.name as FormattingCommand]) {
- // We could multiple levels deep in bold/non-bold. So bail out in this case
- return null
- }
- }
- return null
- }
- function adjustRangeIfNeeded(
- nodeType: FormattingNodeType,
- range: SelectionRange,
- state: EditorState
- ) {
- // Try to adjust the selection, if it is either
- // 1. \textbf<{test>}
- // 2. \textbf{<test}>
- // 3. \textbf<{test}>
- // 4. <\textbf{test}>
- // 4. \textbf<>{test}
- const tree = syntaxTree(state)
- if (tree.length < range.to) {
- return range
- }
- const nodeLeft = tree.resolveInner(range.from, 1)
- const nodeRight = tree.resolveInner(range.to, -1)
- const parentLeft = getWrappingAncestor(nodeLeft, nodeType)
- const parentRight = getWrappingAncestor(nodeRight, nodeType)
- const parent = getParentNode(nodeLeft, state)
- if (
- parent?.type.is('$ToggleTextFormattingCommand') &&
- spansWholeArgument(parent, range)
- ) {
- return bubbleUpRange(
- nodeType,
- ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
- range
- )
- }
- if (!parentLeft) {
- // We're not trying to unbold, so don't bother adjusting range
- return bubbleUpRange(
- nodeType,
- ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
- range
- )
- }
- if (nodeLeft.type.is('$CtrlSeq') && range.from === range.to) {
- const commandNode = nodeLeft.parent?.parent?.parent
- if (!commandNode) {
- return range
- }
- return EditorSelection.cursor(commandNode.from)
- }
- let { from, to } = range
- if (nodeLeft.type.is('$CtrlSeq')) {
- from = nodeLeft.to + 1
- }
- if (nodeLeft.type.is('OpenBrace')) {
- from = nodeLeft.to
- }
- // We know that parentLeft is the $ToggleTextFormattingCommand, so now we check if we're
- // to the right of the closing brace. (parent is TextArgument, grandparent is
- // $ToggleTextFormattingCommand)
- if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
- to = nodeRight.from
- }
- return bubbleUpRange(nodeType, parentLeft, moveRange(range, from, to))
- }
- function spansWholeArgument(
- commandNode: SyntaxNode | null,
- range: SelectionRange
- ): boolean {
- const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
- return Boolean(
- argument && argument.from === range.from && argument.to === range.to
- )
- }
- function bubbleUpRange(
- nodeType: string | number,
- node: SyntaxNode | null,
- range: SelectionRange
- ) {
- let currentRange = range
- for (
- let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
- node,
- '$ToggleTextFormattingCommand'
- );
- spansWholeArgument(ancestorCommand, currentRange);
- ancestorCommand = ancestorOfNodeWithType(
- ancestorCommand.parent,
- '$ToggleTextFormattingCommand'
- )
- ) {
- if (!ancestorCommand) {
- break
- }
- currentRange = moveRange(
- currentRange,
- ancestorCommand.from,
- ancestorCommand.to
- )
- if (ancestorCommand.type.is(nodeType)) {
- const argumentNode = ancestorCommand
- .getChild('TextArgument')
- ?.getChild('LongArg')
- if (!argumentNode) {
- return range
- }
- return moveRange(range, argumentNode.from, argumentNode.to)
- }
- }
- return range
- }
- export function toggleRanges(command: FormattingCommand) {
- const nodeType: FormattingNodeType = formattingCommandMap[command]
- /* There are a number of situations we need to handle in this function.
- * In the following examples, the selection range is marked within <>
- * 1. If the parent node at start and end of selection is different -> do
- * nothing & show error. Case 8 is an exception to this.
- * -> For good and bad, this disallows \textbf{this <is} weird
- * \textit{to> do}
- * 2. If selection contains a BlankLine (i.e. two newlines in a row) -> do
- * nothing & show error
- * -> \textbf doesn't allow paragraph breaks).
- * 3. If the selection is not within a \textbf -> wrap it in a \textbf
- * 4. If selection is at the beginning of a \textbf -> shrink the \textbf
- * command
- * -> e.g. \textbf{<this is> a test} → <this is>\textbf{ a test}
- * 5. Similarly for selection at end of \textbf command
- * 6. If selection is in the middle of a \textbf command -> split the command
- * into two
- * -> e.g. \textbf{this <is a> test} → \textbf{this }<is a>\textbf{ test}
- * 7. If the selection is a whole \textbf command → remove the wrapping
- * command.
- * 8. If the selection spans two \textbf's with the same parent then join the
- * two
- * -> e.g. \textbf{this <is} a \textbf{te>st} → \textbf{this <is a te>st}
- * 9. If the selection spans the end of a \textbf, into the parent of the
- * command, then extend the \textbf.
- * -> e.g. \textbf{this <is} a test> → \textbf{this <is a test>}
- * 10. Similarly for selections spanning the beginning of the selection
- */
- return (view: EditorView): boolean => {
- if (view.state.readOnly) {
- return false
- }
- view.dispatch(
- view.state.changeByRange(initialRange => {
- const range = adjustRangeIfNeeded(nodeType, initialRange, view.state)
- const content = view.state.sliceDoc(range.from, range.to)
- const ancestorAtStartOfRange = getParentNode(
- range.from,
- view.state,
- range.from === 0 ? 1 : -1
- )
- const ancestorAtEndOfRange = range.empty
- ? ancestorAtStartOfRange
- : getParentNode(
- range.to,
- view.state,
- range.to === view.state.doc.length ? -1 : 1
- )
- const tree = ensureSyntaxTree(view.state, 1000)
- const nodeAtFrom = tree?.resolveInner(range.from, 1)
- const nodeAtTo = tree?.resolveInner(range.to, -1)
- const isSingleNodeSelected = nodeAtFrom === nodeAtTo
- if (
- !isSingleNodeSelected &&
- ancestorAtStartOfRange !== ancestorAtEndOfRange
- ) {
- // But handle the exception of case 8
- const ancestorAtStartIsWrappingCommand =
- ancestorAtStartOfRange?.type.is(nodeType)
- const ancestorAtEndIsWrappingCommand =
- ancestorAtEndOfRange && ancestorAtEndOfRange.type.is(nodeType)
- if (
- ancestorAtStartIsWrappingCommand &&
- ancestorAtEndIsWrappingCommand &&
- ancestorAtStartOfRange?.parent?.parent?.parent &&
- ancestorAtEndOfRange?.parent?.parent?.parent
- ) {
- // Test for case 8
- const nextAncestorAtStartOfRange =
- ancestorAtStartOfRange.parent.parent.parent
- const nextAncestorAtEndOfRange =
- ancestorAtEndOfRange.parent.parent.parent
- if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
- // Join the two ranges
- const textBetweenRanges = view.state.sliceDoc(
- ancestorAtStartOfRange.to,
- ancestorAtEndOfRange.from
- )
- const ancestorStartArgumentNode =
- ancestorAtStartOfRange.lastChild?.getChild('LongArg')
- const ancestorEndArgumentNode =
- ancestorAtEndOfRange.lastChild?.getChild('LongArg')
- if (!ancestorStartArgumentNode || !ancestorEndArgumentNode) {
- throw new Error("Can't find argument node")
- }
- const actualContent = view.state.sliceDoc(
- ancestorAtStartOfRange.from,
- ancestorAtEndOfRange.to
- )
- const firstCommandArgument = view.state.sliceDoc(
- ancestorStartArgumentNode.from,
- ancestorStartArgumentNode.to
- )
- const secondCommandArgument = view.state.sliceDoc(
- ancestorEndArgumentNode.from,
- ancestorEndArgumentNode.to
- )
- validateReplacement(
- `${command}{${firstCommandArgument}}${textBetweenRanges}${command}{${secondCommandArgument}}`,
- actualContent
- )
- const changes = view.state.changes([
- {
- from: ancestorAtStartOfRange.from,
- to: ancestorAtEndOfRange.to,
- insert: `${command}{${firstCommandArgument}${textBetweenRanges}${secondCommandArgument}}`,
- },
- ])
- return {
- changes,
- range: moveRange(
- range,
- range.from,
- range.to - command.length - 1 - 1
- ),
- }
- }
- }
- if (
- ancestorAtEndIsWrappingCommand &&
- ancestorAtEndOfRange.parent?.parent?.parent ===
- ancestorAtStartOfRange
- ) {
- // Extend to the left. Case 10
- const contentUpToCommand = view.state.sliceDoc(
- range.from,
- ancestorAtEndOfRange.from
- )
- const ancestorEndArgumentNode =
- ancestorAtEndOfRange.lastChild?.getChild('LongArg')
- if (!ancestorEndArgumentNode) {
- throw new Error("Can't find argument node")
- }
- const commandContent = view.state.sliceDoc(
- ancestorEndArgumentNode.from,
- ancestorEndArgumentNode.to
- )
- const actualContent = view.state.sliceDoc(
- range.from,
- ancestorAtEndOfRange.to
- )
- validateReplacement(
- `${contentUpToCommand}${command}{${commandContent}}`,
- actualContent
- )
- const changes = view.state.changes([
- {
- from: range.from,
- to: ancestorAtEndOfRange.to,
- insert: `${command}{${contentUpToCommand}${commandContent}}`,
- },
- ])
- return {
- changes,
- range: moveRange(
- range,
- range.from + command.length + 1,
- range.to
- ),
- }
- }
- if (
- ancestorAtStartIsWrappingCommand &&
- ancestorAtStartOfRange &&
- ancestorAtStartOfRange.parent?.parent?.parent ===
- ancestorAtEndOfRange
- ) {
- // Extend to the right. Case 9
- const contentAfterCommand = view.state.sliceDoc(
- ancestorAtStartOfRange.to,
- range.to
- )
- const ancestorStartArgumentNode =
- ancestorAtStartOfRange.lastChild?.getChild('LongArg')
- if (!ancestorStartArgumentNode) {
- throw new Error("Can't find argument node")
- }
- const commandContent = view.state.sliceDoc(
- ancestorStartArgumentNode.from,
- ancestorStartArgumentNode.to
- )
- const actualContent = view.state.sliceDoc(
- ancestorAtStartOfRange.from,
- range.to
- )
- validateReplacement(
- `${command}{${commandContent}}${contentAfterCommand}`,
- actualContent
- )
- const changes = view.state.changes([
- {
- from: ancestorAtStartOfRange.from,
- to: range.to,
- insert: `${command}{${commandContent}${contentAfterCommand}}`,
- },
- ])
- return {
- changes,
- range: moveRange(range, range.from, range.to - 1),
- }
- }
- // Bail out in case 1
- // TODO: signal error to the user
- return { range }
- }
- const ancestor = ancestorAtStartOfRange
- // Bail out in case 2
- if (content.includes('\n\n')) {
- // TODO: signal error to the user
- return { range }
- }
- const isCursorBeforeAncestor =
- range.empty &&
- ancestor &&
- range.from === ancestor.from &&
- ancestor.type.is(nodeType)
- // If we can't find an ancestor node, or if the parent is not an exsting
- // \textbf, then we just wrap it in a range. Case 3.
- if (isCursorBeforeAncestor || !ancestor?.type.is(nodeType)) {
- return wrapRangeInCommand(view.state, range, command)
- }
- const argumentNode = ancestor.lastChild?.getChild('LongArg')
- if (!argumentNode) {
- throw new Error("Can't find argument node")
- }
- // We should trim from the beginning. Case 4
- if (range.from === argumentNode.from && range.to !== argumentNode.to) {
- const textAfterSelection = view.state.sliceDoc(
- range.to,
- argumentNode.to
- )
- const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
- validateReplacement(
- `${command}{${content}${textAfterSelection}}`,
- wholeCommand
- )
- const changes = view.state.changes([
- {
- from: ancestor.from,
- to: ancestor.to,
- insert: `${content}${command}{${textAfterSelection}}`,
- },
- ])
- return {
- range: moveRange(
- range,
- range.from - command.length - 1,
- range.to - command.length - 1
- ),
- changes,
- }
- }
- // We should trim from the end. Case 5
- if (range.to === argumentNode.to && range.from !== argumentNode.from) {
- const textBeforeSelection = view.state.sliceDoc(
- ancestor.from,
- range.from
- )
- const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
- validateReplacement(`${textBeforeSelection}${content}}`, wholeCommand)
- const changes = view.state.changes([
- {
- from: ancestor.from,
- to: ancestor.to,
- insert: `${textBeforeSelection}}${content}`,
- },
- ])
- // We should shift selection forward by the } we insert
- return {
- range: moveRange(range, range.from + 1, range.to + 1),
- changes,
- }
- }
- // We should split the command in two. Case 6
- if (range.from !== argumentNode.from && range.to !== argumentNode.to) {
- const textBeforeSelection = view.state.sliceDoc(
- ancestor.from,
- range.from
- )
- const textAfterSelection = view.state.sliceDoc(range.to, ancestor.to)
- const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
- validateReplacement(
- `${textBeforeSelection}${content}${textAfterSelection}`,
- wholeCommand
- )
- const changes = view.state.changes([
- {
- from: ancestor.from,
- to: ancestor.to,
- insert: `${textBeforeSelection}}${content}${command}{${textAfterSelection}`,
- },
- ])
- return {
- range: moveRange(range, range.from + 1, range.to + 1),
- changes,
- }
- }
- // Remove the wrapping command. Case 7
- if (spansWholeArgument(ancestor, range)) {
- const argumentContent = view.state.sliceDoc(
- argumentNode.from,
- argumentNode.to
- )
- const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
- validateReplacement(`${command}{${content}}`, wholeCommand)
- const changes = view.state.changes([
- { from: ancestor.from, to: ancestor.to, insert: argumentContent },
- ])
- return {
- range: moveRange(
- range,
- range.from - command.length - 1,
- range.to - command.length - 1
- ),
- changes,
- }
- }
- // Shouldn't happen, but default to just wrapping the content
- return wrapRangeInCommand(view.state, range, command)
- }),
- { scrollIntoView: true }
- )
- return true
- }
- }
|