mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Add text formatting commands to the LaTeX grammar (#19607)
GitOrigin-RevId: f69cd323992c80de3f0a458a637fa8f160017076
This commit is contained in:
@@ -7,11 +7,13 @@ import {
|
|||||||
syntaxTree,
|
syntaxTree,
|
||||||
} from '@codemirror/language'
|
} from '@codemirror/language'
|
||||||
import { SyntaxNode } from '@lezer/common'
|
import { SyntaxNode } from '@lezer/common'
|
||||||
import {
|
import { ancestorOfNodeWithType } from '../utils/tree-query'
|
||||||
ancestorOfNodeWithType,
|
|
||||||
isUnknownCommandWithName,
|
|
||||||
} from '../utils/tree-query'
|
|
||||||
import { lastAncestorAtEndPosition } from '../utils/tree-operations/ancestors'
|
import { lastAncestorAtEndPosition } from '../utils/tree-operations/ancestors'
|
||||||
|
import {
|
||||||
|
formattingCommandMap,
|
||||||
|
type FormattingCommand,
|
||||||
|
type FormattingNodeType,
|
||||||
|
} from '@/features/source-editor/utils/tree-operations/formatting'
|
||||||
|
|
||||||
export const wrapRanges =
|
export const wrapRanges =
|
||||||
(
|
(
|
||||||
@@ -208,13 +210,20 @@ export const duplicateSelection = (view: EditorView) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skipParentNodeTypes = [
|
||||||
|
'LongArg',
|
||||||
|
'TextArgument',
|
||||||
|
'OpenBrace',
|
||||||
|
'CloseBrace',
|
||||||
|
]
|
||||||
|
|
||||||
function getParentNode(
|
function getParentNode(
|
||||||
position: number | SyntaxNode,
|
position: number | SyntaxNode,
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
assoc: 0 | 1 | -1 = 1
|
assoc: 0 | 1 | -1 = 1
|
||||||
): SyntaxNode | undefined {
|
): SyntaxNode | undefined {
|
||||||
const tree = ensureSyntaxTree(state, 1000)
|
const tree = ensureSyntaxTree(state, 1000)
|
||||||
let node: SyntaxNode | undefined | null = null
|
let node: SyntaxNode | undefined | null
|
||||||
if (typeof position === 'number') {
|
if (typeof position === 'number') {
|
||||||
node = tree?.resolveInner(position, assoc)?.parent
|
node = tree?.resolveInner(position, assoc)?.parent
|
||||||
|
|
||||||
@@ -226,13 +235,10 @@ function getParentNode(
|
|||||||
node = position?.parent
|
node = position?.parent
|
||||||
}
|
}
|
||||||
|
|
||||||
while (
|
while (node && skipParentNodeTypes.includes(node.type.name)) {
|
||||||
['LongArg', 'TextArgument', 'OpenBrace', 'CloseBrace'].includes(
|
node = node.parent
|
||||||
node?.type.name || ''
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
node = node!.parent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return node || undefined
|
return node || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,18 +280,17 @@ function validateReplacement(expected: string, actual: string) {
|
|||||||
|
|
||||||
function getWrappingAncestor(
|
function getWrappingAncestor(
|
||||||
node: SyntaxNode,
|
node: SyntaxNode,
|
||||||
command: string,
|
nodeType: FormattingNodeType
|
||||||
state: EditorState
|
|
||||||
): SyntaxNode | null {
|
): SyntaxNode | null {
|
||||||
for (
|
for (
|
||||||
let ancestor: SyntaxNode | null = node;
|
let ancestor: SyntaxNode | null = node;
|
||||||
ancestor;
|
ancestor;
|
||||||
ancestor = ancestor.parent
|
ancestor = ancestor.parent
|
||||||
) {
|
) {
|
||||||
if (isUnknownCommandWithName(ancestor, command, state)) {
|
if (ancestor.type.is(nodeType)) {
|
||||||
return ancestor
|
return ancestor
|
||||||
}
|
}
|
||||||
if (ancestor.type.is('UnknownCommand')) {
|
if (formattingCommandMap[ancestor.type.name as FormattingCommand]) {
|
||||||
// We could multiple levels deep in bold/non-bold. So bail out in this case
|
// We could multiple levels deep in bold/non-bold. So bail out in this case
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -294,7 +299,7 @@ function getWrappingAncestor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adjustRangeIfNeeded(
|
function adjustRangeIfNeeded(
|
||||||
command: string,
|
nodeType: FormattingNodeType,
|
||||||
range: SelectionRange,
|
range: SelectionRange,
|
||||||
state: EditorState
|
state: EditorState
|
||||||
) {
|
) {
|
||||||
@@ -311,50 +316,51 @@ function adjustRangeIfNeeded(
|
|||||||
|
|
||||||
const nodeLeft = tree.resolveInner(range.from, 1)
|
const nodeLeft = tree.resolveInner(range.from, 1)
|
||||||
const nodeRight = tree.resolveInner(range.to, -1)
|
const nodeRight = tree.resolveInner(range.to, -1)
|
||||||
const parentLeft = getWrappingAncestor(nodeLeft, command, state)
|
const parentLeft = getWrappingAncestor(nodeLeft, nodeType)
|
||||||
const parentRight = getWrappingAncestor(nodeRight, command, state)
|
const parentRight = getWrappingAncestor(nodeRight, nodeType)
|
||||||
|
|
||||||
const parent = getParentNode(nodeLeft, state)
|
const parent = getParentNode(nodeLeft, state)
|
||||||
if (parent?.type.is('UnknownCommand') && spansWholeArgument(parent, range)) {
|
if (
|
||||||
|
parent?.type.is('$ToggleTextFormattingCommand') &&
|
||||||
|
spansWholeArgument(parent, range)
|
||||||
|
) {
|
||||||
return bubbleUpRange(
|
return bubbleUpRange(
|
||||||
command,
|
nodeType,
|
||||||
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
|
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
|
||||||
range,
|
range
|
||||||
state
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parentLeft) {
|
if (!parentLeft) {
|
||||||
// We're not trying to unbold, so don't bother adjusting range
|
// We're not trying to unbold, so don't bother adjusting range
|
||||||
return bubbleUpRange(
|
return bubbleUpRange(
|
||||||
command,
|
nodeType,
|
||||||
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
|
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
|
||||||
range,
|
range
|
||||||
state
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (nodeLeft.type.is('CtrlSeq') && range.from === range.to) {
|
if (nodeLeft.type.is('$CtrlSeq') && range.from === range.to) {
|
||||||
const command = nodeLeft.parent?.parent
|
const commandNode = nodeLeft.parent?.parent?.parent
|
||||||
if (!command) {
|
if (!commandNode) {
|
||||||
return range
|
return range
|
||||||
}
|
}
|
||||||
return EditorSelection.cursor(command.from)
|
return EditorSelection.cursor(commandNode.from)
|
||||||
}
|
}
|
||||||
|
|
||||||
let { from, to } = range
|
let { from, to } = range
|
||||||
if (nodeLeft.type.is('CtrlSeq')) {
|
if (nodeLeft.type.is('$CtrlSeq')) {
|
||||||
from = nodeLeft.to + 1
|
from = nodeLeft.to + 1
|
||||||
}
|
}
|
||||||
if (nodeLeft.type.is('OpenBrace')) {
|
if (nodeLeft.type.is('OpenBrace')) {
|
||||||
from = nodeLeft.to
|
from = nodeLeft.to
|
||||||
}
|
}
|
||||||
// We know that parentLeft is the UnknownCommand, so now we check if we're
|
// 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
|
// to the right of the closing brace. (parent is TextArgument, grandparent is
|
||||||
// UnknownCommand)
|
// $ToggleTextFormattingCommand)
|
||||||
if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
|
if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
|
||||||
to = nodeRight.from
|
to = nodeRight.from
|
||||||
}
|
}
|
||||||
return bubbleUpRange(command, parentLeft, moveRange(range, from, to), state)
|
return bubbleUpRange(nodeType, parentLeft, moveRange(range, from, to))
|
||||||
}
|
}
|
||||||
|
|
||||||
function spansWholeArgument(
|
function spansWholeArgument(
|
||||||
@@ -362,28 +368,26 @@ function spansWholeArgument(
|
|||||||
range: SelectionRange
|
range: SelectionRange
|
||||||
): boolean {
|
): boolean {
|
||||||
const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
|
const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
|
||||||
const res = Boolean(
|
return Boolean(
|
||||||
argument && argument.from === range.from && argument.to === range.to
|
argument && argument.from === range.from && argument.to === range.to
|
||||||
)
|
)
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bubbleUpRange(
|
function bubbleUpRange(
|
||||||
command: string,
|
nodeType: string | number,
|
||||||
node: SyntaxNode | null,
|
node: SyntaxNode | null,
|
||||||
range: SelectionRange,
|
range: SelectionRange
|
||||||
state: EditorState
|
|
||||||
) {
|
) {
|
||||||
let currentRange = range
|
let currentRange = range
|
||||||
for (
|
for (
|
||||||
let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
|
let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
|
||||||
node,
|
node,
|
||||||
'UnknownCommand'
|
'$ToggleTextFormattingCommand'
|
||||||
);
|
);
|
||||||
spansWholeArgument(ancestorCommand, currentRange);
|
spansWholeArgument(ancestorCommand, currentRange);
|
||||||
ancestorCommand = ancestorOfNodeWithType(
|
ancestorCommand = ancestorOfNodeWithType(
|
||||||
ancestorCommand.parent,
|
ancestorCommand.parent,
|
||||||
'UnknownCommand'
|
'$ToggleTextFormattingCommand'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (!ancestorCommand) {
|
if (!ancestorCommand) {
|
||||||
@@ -394,7 +398,7 @@ function bubbleUpRange(
|
|||||||
ancestorCommand.from,
|
ancestorCommand.from,
|
||||||
ancestorCommand.to
|
ancestorCommand.to
|
||||||
)
|
)
|
||||||
if (isUnknownCommandWithName(ancestorCommand, command, state)) {
|
if (ancestorCommand.type.is(nodeType)) {
|
||||||
const argumentNode = ancestorCommand
|
const argumentNode = ancestorCommand
|
||||||
.getChild('TextArgument')
|
.getChild('TextArgument')
|
||||||
?.getChild('LongArg')
|
?.getChild('LongArg')
|
||||||
@@ -408,7 +412,9 @@ function bubbleUpRange(
|
|||||||
return range
|
return range
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleRanges(command: string) {
|
export function toggleRanges(command: FormattingCommand) {
|
||||||
|
const nodeType: FormattingNodeType = formattingCommandMap[command]
|
||||||
|
|
||||||
/* There are a number of situations we need to handle in this function.
|
/* There are a number of situations we need to handle in this function.
|
||||||
* In the following examples, the selection range is marked within <>
|
* In the following examples, the selection range is marked within <>
|
||||||
|
|
||||||
@@ -443,7 +449,7 @@ export function toggleRanges(command: string) {
|
|||||||
}
|
}
|
||||||
view.dispatch(
|
view.dispatch(
|
||||||
view.state.changeByRange(initialRange => {
|
view.state.changeByRange(initialRange => {
|
||||||
const range = adjustRangeIfNeeded(command, initialRange, view.state)
|
const range = adjustRangeIfNeeded(nodeType, initialRange, view.state)
|
||||||
const content = view.state.sliceDoc(range.from, range.to)
|
const content = view.state.sliceDoc(range.from, range.to)
|
||||||
|
|
||||||
const ancestorAtStartOfRange = getParentNode(
|
const ancestorAtStartOfRange = getParentNode(
|
||||||
@@ -470,25 +476,22 @@ export function toggleRanges(command: string) {
|
|||||||
) {
|
) {
|
||||||
// But handle the exception of case 8
|
// But handle the exception of case 8
|
||||||
const ancestorAtStartIsWrappingCommand =
|
const ancestorAtStartIsWrappingCommand =
|
||||||
ancestorAtStartOfRange &&
|
ancestorAtStartOfRange?.type.is(nodeType)
|
||||||
isUnknownCommandWithName(
|
|
||||||
ancestorAtStartOfRange,
|
|
||||||
command,
|
|
||||||
view.state
|
|
||||||
)
|
|
||||||
const ancestorAtEndIsWrappingCommand =
|
const ancestorAtEndIsWrappingCommand =
|
||||||
ancestorAtEndOfRange &&
|
ancestorAtEndOfRange && ancestorAtEndOfRange.type.is(nodeType)
|
||||||
isUnknownCommandWithName(ancestorAtEndOfRange, command, view.state)
|
|
||||||
if (
|
if (
|
||||||
ancestorAtStartIsWrappingCommand &&
|
ancestorAtStartIsWrappingCommand &&
|
||||||
ancestorAtEndIsWrappingCommand &&
|
ancestorAtEndIsWrappingCommand &&
|
||||||
ancestorAtStartOfRange?.parent?.parent &&
|
ancestorAtStartOfRange?.parent?.parent?.parent &&
|
||||||
ancestorAtEndOfRange?.parent?.parent
|
ancestorAtEndOfRange?.parent?.parent?.parent
|
||||||
) {
|
) {
|
||||||
// Test for case 8
|
// Test for case 8
|
||||||
const nextAncestorAtStartOfRange =
|
const nextAncestorAtStartOfRange =
|
||||||
ancestorAtStartOfRange.parent.parent
|
ancestorAtStartOfRange.parent.parent.parent
|
||||||
const nextAncestorAtEndOfRange = ancestorAtEndOfRange.parent.parent
|
const nextAncestorAtEndOfRange =
|
||||||
|
ancestorAtEndOfRange.parent.parent.parent
|
||||||
|
|
||||||
if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
|
if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
|
||||||
// Join the two ranges
|
// Join the two ranges
|
||||||
@@ -539,7 +542,8 @@ export function toggleRanges(command: string) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
ancestorAtEndIsWrappingCommand &&
|
ancestorAtEndIsWrappingCommand &&
|
||||||
ancestorAtEndOfRange.parent?.parent === ancestorAtStartOfRange
|
ancestorAtEndOfRange.parent?.parent?.parent ===
|
||||||
|
ancestorAtStartOfRange
|
||||||
) {
|
) {
|
||||||
// Extend to the left. Case 10
|
// Extend to the left. Case 10
|
||||||
const contentUpToCommand = view.state.sliceDoc(
|
const contentUpToCommand = view.state.sliceDoc(
|
||||||
@@ -582,7 +586,9 @@ export function toggleRanges(command: string) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
ancestorAtStartIsWrappingCommand &&
|
ancestorAtStartIsWrappingCommand &&
|
||||||
ancestorAtStartOfRange.parent?.parent === ancestorAtEndOfRange
|
ancestorAtStartOfRange &&
|
||||||
|
ancestorAtStartOfRange.parent?.parent?.parent ===
|
||||||
|
ancestorAtEndOfRange
|
||||||
) {
|
) {
|
||||||
// Extend to the right. Case 9
|
// Extend to the right. Case 9
|
||||||
const contentAfterCommand = view.state.sliceDoc(
|
const contentAfterCommand = view.state.sliceDoc(
|
||||||
@@ -635,15 +641,11 @@ export function toggleRanges(command: string) {
|
|||||||
range.empty &&
|
range.empty &&
|
||||||
ancestor &&
|
ancestor &&
|
||||||
range.from === ancestor.from &&
|
range.from === ancestor.from &&
|
||||||
isUnknownCommandWithName(ancestor, command, view.state)
|
ancestor.type.is(nodeType)
|
||||||
|
|
||||||
// If we can't find an ancestor node, or if the parent is not an exsting
|
// 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.
|
// \textbf, then we just wrap it in a range. Case 3.
|
||||||
if (
|
if (isCursorBeforeAncestor || !ancestor?.type.is(nodeType)) {
|
||||||
isCursorBeforeAncestor ||
|
|
||||||
!ancestor ||
|
|
||||||
!isUnknownCommandWithName(ancestor, command, view.state)
|
|
||||||
) {
|
|
||||||
return wrapRangeInCommand(view.state, range, command)
|
return wrapRangeInCommand(view.state, range, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,6 @@
|
|||||||
import { FC, memo } from 'react'
|
import { FC, memo } from 'react'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState } from '@codemirror/state'
|
||||||
import { useEditorContext } from '../../../../shared/context/editor-context'
|
import { useEditorContext } from '../../../../shared/context/editor-context'
|
||||||
import { withinFormattingCommand } from '../../utils/tree-operations/ancestors'
|
|
||||||
import { ToolbarButton } from './toolbar-button'
|
import { ToolbarButton } from './toolbar-button'
|
||||||
import { redo, undo } from '@codemirror/commands'
|
import { redo, undo } from '@codemirror/commands'
|
||||||
import * as commands from '../../extensions/toolbar/commands'
|
import * as commands from '../../extensions/toolbar/commands'
|
||||||
@@ -11,6 +10,7 @@ import { InsertFigureDropdown } from './insert-figure-dropdown'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MathDropdown } from './math-dropdown'
|
import { MathDropdown } from './math-dropdown'
|
||||||
import { TableInserterDropdown } from './table-inserter-dropdown'
|
import { TableInserterDropdown } from './table-inserter-dropdown'
|
||||||
|
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
|
||||||
|
|
||||||
const isMac = /Mac/.test(window.navigator?.platform)
|
const isMac = /Mac/.test(window.navigator?.platform)
|
||||||
|
|
||||||
|
|||||||
+31
-41
@@ -1038,11 +1038,38 @@ export const atomicDecorations = (options: Options) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (nodeRef.type.is('$ToggleTextFormattingCommand')) {
|
||||||
|
// markup that can be toggled using toolbar buttons/keyboard shortcuts
|
||||||
|
const textArgumentNode = nodeRef.node.getChild('TextArgument')
|
||||||
|
const argumentText = textArgumentNode?.getChild('LongArg')
|
||||||
|
const shouldShowBraces =
|
||||||
|
!shouldDecorate(state, nodeRef) ||
|
||||||
|
argumentText?.from === argumentText?.to
|
||||||
|
decorations.push(
|
||||||
|
...decorateArgumentBraces(
|
||||||
|
new BraceWidget(shouldShowBraces ? '{' : ''),
|
||||||
|
textArgumentNode,
|
||||||
|
nodeRef.from,
|
||||||
|
true,
|
||||||
|
new BraceWidget(shouldShowBraces ? '}' : '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (nodeRef.type.is('$OtherTextFormattingCommand')) {
|
||||||
|
// markup that can't be toggled using toolbar buttons/keyboard shortcuts
|
||||||
|
const textArgumentNode = nodeRef.node.getChild('TextArgument')
|
||||||
|
if (shouldDecorate(state, nodeRef)) {
|
||||||
|
decorations.push(
|
||||||
|
...decorateArgumentBraces(
|
||||||
|
new BraceWidget(),
|
||||||
|
textArgumentNode,
|
||||||
|
nodeRef.from
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (nodeRef.type.is('UnknownCommand')) {
|
} else if (nodeRef.type.is('UnknownCommand')) {
|
||||||
// a command that's not defined separately by the grammar
|
// a command that's not defined separately by the grammar
|
||||||
const commandNode = nodeRef.node
|
const commandNode = nodeRef.node
|
||||||
const commandNameNode = commandNode.getChild('$CtrlSeq')
|
const commandNameNode = commandNode.getChild('$CtrlSeq')
|
||||||
const textArgumentNode = commandNode.getChild('TextArgument')
|
|
||||||
|
|
||||||
if (commandNameNode) {
|
if (commandNameNode) {
|
||||||
const commandName = state.doc
|
const commandName = state.doc
|
||||||
@@ -1050,46 +1077,9 @@ export const atomicDecorations = (options: Options) => {
|
|||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
if (commandName.length > 0) {
|
if (commandName.length > 0) {
|
||||||
if (
|
const textArgumentNode = commandNode.getChild('TextArgument')
|
||||||
// markup that can be toggled using toolbar buttons/keyboard shortcuts
|
|
||||||
['\\textbf', '\\textit', '\\underline'].includes(commandName)
|
if (commandName === '\\keywords') {
|
||||||
) {
|
|
||||||
const argumentText = textArgumentNode?.getChild('LongArg')
|
|
||||||
const shouldShowBraces =
|
|
||||||
!shouldDecorate(state, nodeRef) ||
|
|
||||||
argumentText?.from === argumentText?.to
|
|
||||||
decorations.push(
|
|
||||||
...decorateArgumentBraces(
|
|
||||||
new BraceWidget(shouldShowBraces ? '{' : ''),
|
|
||||||
textArgumentNode,
|
|
||||||
nodeRef.from,
|
|
||||||
true,
|
|
||||||
new BraceWidget(shouldShowBraces ? '}' : '')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else if (
|
|
||||||
// markup that can't be toggled using toolbar buttons/keyboard shortcuts
|
|
||||||
[
|
|
||||||
'\\textsc',
|
|
||||||
'\\texttt',
|
|
||||||
'\\textmd',
|
|
||||||
'\\textsf',
|
|
||||||
'\\textsuperscript',
|
|
||||||
'\\textsubscript',
|
|
||||||
'\\sout',
|
|
||||||
'\\emph',
|
|
||||||
].includes(commandName)
|
|
||||||
) {
|
|
||||||
if (shouldDecorate(state, nodeRef)) {
|
|
||||||
decorations.push(
|
|
||||||
...decorateArgumentBraces(
|
|
||||||
new BraceWidget(),
|
|
||||||
textArgumentNode,
|
|
||||||
nodeRef.from
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (commandName === '\\keywords') {
|
|
||||||
if (shouldDecorate(state, nodeRef)) {
|
if (shouldDecorate(state, nodeRef)) {
|
||||||
// command name and opening brace
|
// command name and opening brace
|
||||||
decorations.push(
|
decorations.push(
|
||||||
|
|||||||
+113
-100
@@ -2,59 +2,49 @@ import { EditorState } from '@codemirror/state'
|
|||||||
import { SyntaxNode } from '@lezer/common'
|
import { SyntaxNode } from '@lezer/common'
|
||||||
import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character'
|
import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character'
|
||||||
|
|
||||||
const isUnknownCommandWithName = (
|
type Markup = {
|
||||||
node: SyntaxNode,
|
|
||||||
command: string,
|
|
||||||
getText: (from: number, to: number) => string
|
|
||||||
): boolean => {
|
|
||||||
const commandName = getUnknownCommandName(node, getText)
|
|
||||||
if (commandName === undefined) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return commandName === command
|
|
||||||
}
|
|
||||||
|
|
||||||
const getUnknownCommandName = (
|
|
||||||
node: SyntaxNode,
|
|
||||||
getText: (from: number, to: number) => string
|
|
||||||
): string | undefined => {
|
|
||||||
if (!node.type.is('UnknownCommand')) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const commandNameNode = node.getChild('CtrlSeq')
|
|
||||||
if (!commandNameNode) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const commandName = getText(commandNameNode.from, commandNameNode.to)
|
|
||||||
return commandName
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeMapping = {
|
|
||||||
elementType: keyof HTMLElementTagNameMap
|
elementType: keyof HTMLElementTagNameMap
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
type MarkupMapping = {
|
|
||||||
[command: string]: NodeMapping
|
const textFormattingMarkupMap = new Map<string, Markup>([
|
||||||
}
|
[
|
||||||
const MARKUP_COMMANDS: MarkupMapping = {
|
'TextBoldCommand', // \\textbf
|
||||||
'\\textit': {
|
{ elementType: 'b' },
|
||||||
elementType: 'i',
|
],
|
||||||
},
|
[
|
||||||
'\\textbf': {
|
'TextItalicCommand', // \\textit
|
||||||
elementType: 'b',
|
{ elementType: 'i' },
|
||||||
},
|
],
|
||||||
'\\emph': {
|
[
|
||||||
elementType: 'em',
|
'TextSmallCapsCommand', // \\textsc
|
||||||
},
|
{ elementType: 'span', className: 'ol-cm-command-textsc' },
|
||||||
'\\texttt': {
|
],
|
||||||
elementType: 'span',
|
[
|
||||||
className: 'ol-cm-command-texttt',
|
'TextTeletypeCommand', // \\texttt
|
||||||
},
|
{ elementType: 'span', className: 'ol-cm-command-texttt' },
|
||||||
'\\textsc': {
|
],
|
||||||
elementType: 'span',
|
[
|
||||||
className: 'ol-cm-command-textsc',
|
'TextSuperscriptCommand', // \\textsuperscript
|
||||||
},
|
{ elementType: 'sup' },
|
||||||
}
|
],
|
||||||
|
[
|
||||||
|
'TextSubscriptCommand', // \\textsubscript
|
||||||
|
{ elementType: 'sub' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EmphasisCommand', // \\emph
|
||||||
|
{ elementType: 'em' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'UnderlineCommand', // \\underline
|
||||||
|
{ elementType: 'span', className: 'ol-cm-command-underline' },
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
const markupMap = new Map<string, Markup>([
|
||||||
|
['\\and', { elementType: 'span', className: 'ol-cm-command-and' }],
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does a small amount of typesetting of LaTeX content into a DOM element.
|
* Does a small amount of typesetting of LaTeX content into a DOM element.
|
||||||
@@ -62,35 +52,44 @@ const MARKUP_COMMANDS: MarkupMapping = {
|
|||||||
* function if you wish to typeset math content.
|
* function if you wish to typeset math content.
|
||||||
* @param node The syntax node containing the text to be typeset
|
* @param node The syntax node containing the text to be typeset
|
||||||
* @param element The DOM element to typeset into
|
* @param element The DOM element to typeset into
|
||||||
* @param state The editor state where `node` is from
|
* @param getText The editor state where `node` is from or a custom function
|
||||||
*/
|
*/
|
||||||
export function typesetNodeIntoElement(
|
export function typesetNodeIntoElement(
|
||||||
node: SyntaxNode,
|
node: SyntaxNode,
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
state: EditorState | ((from: number, to: number) => string)
|
getText: EditorState | ((from: number, to: number) => string)
|
||||||
) {
|
) {
|
||||||
let getText: (from: number, to: number) => string
|
if (getText instanceof EditorState) {
|
||||||
if (typeof state === 'function') {
|
getText = getText.sliceDoc.bind(getText)
|
||||||
getText = state
|
|
||||||
} else {
|
|
||||||
getText = state!.sliceDoc.bind(state!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're a TextArgument node, we should skip the braces
|
// If we're a TextArgument node, we should skip the braces
|
||||||
const argument = node.getChild('LongArg')
|
const argument = node.getChild('LongArg')
|
||||||
if (argument) {
|
if (argument) {
|
||||||
node = argument
|
node = argument
|
||||||
}
|
}
|
||||||
|
|
||||||
const ancestorStack = [element]
|
const ancestorStack = [element]
|
||||||
|
|
||||||
const ancestor = () => ancestorStack[ancestorStack.length - 1]
|
const ancestor = () => ancestorStack[ancestorStack.length - 1]
|
||||||
const popAncestor = () => ancestorStack.pop()!
|
const popAncestor = () => ancestorStack.pop()!
|
||||||
const pushAncestor = (x: HTMLElement) => ancestorStack.push(x)
|
const pushAncestor = (element: HTMLElement) => ancestorStack.push(element)
|
||||||
|
|
||||||
let from = node.from
|
let from = node.from
|
||||||
|
|
||||||
|
const addMarkup = (markup: Markup, childNode: SyntaxNode) => {
|
||||||
|
const element = document.createElement(markup.elementType)
|
||||||
|
if (markup.className) {
|
||||||
|
element.classList.add(markup.className)
|
||||||
|
}
|
||||||
|
pushAncestor(element)
|
||||||
|
from = chooseFrom(childNode)
|
||||||
|
}
|
||||||
|
|
||||||
node.cursor().iterate(
|
node.cursor().iterate(
|
||||||
function enter(childNodeRef) {
|
function enter(childNodeRef) {
|
||||||
const childNode = childNodeRef.node
|
const childNode = childNodeRef.node
|
||||||
|
|
||||||
if (from < childNode.from) {
|
if (from < childNode.from) {
|
||||||
ancestor().append(
|
ancestor().append(
|
||||||
document.createTextNode(getText(from, childNode.from))
|
document.createTextNode(getText(from, childNode.from))
|
||||||
@@ -98,57 +97,43 @@ export function typesetNodeIntoElement(
|
|||||||
from = childNode.from
|
from = childNode.from
|
||||||
}
|
}
|
||||||
|
|
||||||
if (childNode.type.is('UnknownCommand')) {
|
// commands defined in the grammar
|
||||||
const commandNameNode = childNode.getChild('CtrlSeq')
|
const markup = textFormattingMarkupMap.get(childNode.type.name)
|
||||||
if (commandNameNode) {
|
if (markup) {
|
||||||
const commandName = getText(commandNameNode.from, commandNameNode.to)
|
addMarkup(markup, childNode)
|
||||||
const mapping: NodeMapping | undefined = MARKUP_COMMANDS[commandName]
|
return
|
||||||
if (mapping) {
|
|
||||||
const element = document.createElement(mapping.elementType)
|
|
||||||
if (mapping.className) {
|
|
||||||
element.classList.add(mapping.className)
|
|
||||||
}
|
|
||||||
pushAncestor(element)
|
|
||||||
const textArgument = childNode.getChild('TextArgument')
|
|
||||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isUnknownCommandWithName(childNode, '\\and', getText)) {
|
|
||||||
const spanElement = document.createElement('span')
|
// commands not defined in the grammar
|
||||||
spanElement.classList.add('ol-cm-command-and')
|
const commandName = unknownCommandName(childNode, getText)
|
||||||
pushAncestor(spanElement)
|
if (commandName) {
|
||||||
const textArgument = childNode.getChild('TextArgument')
|
const markup = markupMap.get(commandName)
|
||||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
if (markup) {
|
||||||
} else if (
|
addMarkup(markup, childNode)
|
||||||
isUnknownCommandWithName(childNode, '\\corref', getText) ||
|
return
|
||||||
isUnknownCommandWithName(childNode, '\\fnref', getText) ||
|
}
|
||||||
isUnknownCommandWithName(childNode, '\\thanks', getText)
|
|
||||||
) {
|
if (['\\corref', '\\fnref', '\\thanks'].includes(commandName)) {
|
||||||
// ignoring these commands
|
// ignoring these commands
|
||||||
from = childNode.to
|
from = childNode.to
|
||||||
return false
|
return false
|
||||||
} else if (childNode.type.is('LineBreak')) {
|
}
|
||||||
ancestor().appendChild(document.createElement('br'))
|
|
||||||
from = childNode.to
|
const symbol = COMMAND_SUBSTITUTIONS.get(commandName)
|
||||||
} else if (childNode.type.is('UnknownCommand')) {
|
if (symbol) {
|
||||||
const command = getText(childNode.from, childNode.to)
|
|
||||||
const symbol = COMMAND_SUBSTITUTIONS.get(command.trim())
|
|
||||||
if (symbol !== undefined) {
|
|
||||||
ancestor().append(document.createTextNode(symbol))
|
ancestor().append(document.createTextNode(symbol))
|
||||||
from = childNode.to
|
from = childNode.to
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
} else if (childNode.type.is('LineBreak')) {
|
||||||
|
ancestor().append(document.createElement('br'))
|
||||||
|
from = childNode.to
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
function leave(childNodeRef) {
|
function leave(childNodeRef) {
|
||||||
const childNode = childNodeRef.node
|
const childNode = childNodeRef.node
|
||||||
const commandName = getUnknownCommandName(childNode, getText)
|
|
||||||
if (
|
if (shouldHandleLeave(childNode, getText)) {
|
||||||
(commandName && Boolean(MARKUP_COMMANDS[commandName])) ||
|
|
||||||
isUnknownCommandWithName(childNode, '\\and', getText)
|
|
||||||
) {
|
|
||||||
const typeSetElement = popAncestor()
|
const typeSetElement = popAncestor()
|
||||||
ancestor().appendChild(typeSetElement)
|
ancestor().appendChild(typeSetElement)
|
||||||
const textArgument = childNode.getChild('TextArgument')
|
const textArgument = childNode.getChild('TextArgument')
|
||||||
@@ -159,9 +144,37 @@ export function typesetNodeIntoElement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (from < node.to) {
|
if (from < node.to) {
|
||||||
ancestor().append(document.createTextNode(getText(from, node.to)))
|
ancestor().append(document.createTextNode(getText(from, node.to)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chooseFrom = (node: SyntaxNode) =>
|
||||||
|
node.getChild('TextArgument')?.getChild('LongArg')?.from ?? node.to
|
||||||
|
|
||||||
|
const shouldHandleLeave = (
|
||||||
|
node: SyntaxNode,
|
||||||
|
getText: (from: number, to: number) => string
|
||||||
|
) => {
|
||||||
|
if (textFormattingMarkupMap.has(node.type.name)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandName = unknownCommandName(node, getText)
|
||||||
|
return commandName && markupMap.has(commandName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownCommandName = (
|
||||||
|
node: SyntaxNode,
|
||||||
|
getText: (from: number, to: number) => string
|
||||||
|
): string | undefined => {
|
||||||
|
if (node.type.is('UnknownCommand')) {
|
||||||
|
const commandNameNode = node.getChild('$CtrlSeq')
|
||||||
|
if (commandNameNode) {
|
||||||
|
return getText(commandNameNode.from, commandNameNode.to).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { FlatOutlineItem } from '../../utils/tree-query'
|
import { enterNode, FlatOutlineItem } from '../../utils/tree-operations/outline'
|
||||||
import { enterNode } from '../../utils/tree-operations/outline'
|
|
||||||
import { makeProjectionStateField } from '../../utils/projection-state-field'
|
import { makeProjectionStateField } from '../../utils/projection-state-field'
|
||||||
|
|
||||||
export const documentOutline =
|
export const documentOutline =
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ const typeMap: Record<string, string[]> = {
|
|||||||
Input: ['$CommandTooltipCommand'],
|
Input: ['$CommandTooltipCommand'],
|
||||||
Ref: ['$CommandTooltipCommand'],
|
Ref: ['$CommandTooltipCommand'],
|
||||||
UrlCommand: ['$CommandTooltipCommand'],
|
UrlCommand: ['$CommandTooltipCommand'],
|
||||||
|
// text formatting commands that can be toggled via the toolbar
|
||||||
|
TextBoldCommand: ['$ToggleTextFormattingCommand'],
|
||||||
|
TextItalicCommand: ['$ToggleTextFormattingCommand'],
|
||||||
|
// text formatting commands that cannot be toggled via the toolbar
|
||||||
|
TextSmallCapsCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
TextTeletypeCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
TextMediumCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
TextSansSerifCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
TextSuperscriptCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
TextSubscriptCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
StrikeOutCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
EmphasisCommand: ['$OtherTextFormattingCommand'],
|
||||||
|
UnderlineCommand: ['$OtherTextFormattingCommand'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LaTeXLanguage = LRLanguage.define({
|
export const LaTeXLanguage = LRLanguage.define({
|
||||||
|
|||||||
@@ -95,7 +95,18 @@
|
|||||||
MidRuleCtrlSeq,
|
MidRuleCtrlSeq,
|
||||||
BottomRuleCtrlSeq,
|
BottomRuleCtrlSeq,
|
||||||
MultiColumnCtrlSeq,
|
MultiColumnCtrlSeq,
|
||||||
ParBoxCtrlSeq
|
ParBoxCtrlSeq,
|
||||||
|
TextBoldCtrlSeq,
|
||||||
|
TextItalicCtrlSeq,
|
||||||
|
TextSmallCapsCtrlSeq,
|
||||||
|
TextTeletypeCtrlSeq,
|
||||||
|
TextMediumCtrlSeq,
|
||||||
|
TextSansSerifCtrlSeq,
|
||||||
|
TextSuperscriptCtrlSeq,
|
||||||
|
TextSubscriptCtrlSeq,
|
||||||
|
TextStrikeOutCtrlSeq,
|
||||||
|
EmphasisCtrlSeq,
|
||||||
|
UnderlineCtrlSeq
|
||||||
}
|
}
|
||||||
|
|
||||||
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
|
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
|
||||||
@@ -358,6 +369,39 @@ KnownCommand<ArgumentType> {
|
|||||||
(optionalWhitespace? OptionalArgument)*
|
(optionalWhitespace? OptionalArgument)*
|
||||||
ShortTextArgument
|
ShortTextArgument
|
||||||
optionalWhitespace? TextArgument
|
optionalWhitespace? TextArgument
|
||||||
|
} |
|
||||||
|
TextBoldCommand {
|
||||||
|
TextBoldCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextItalicCommand {
|
||||||
|
TextItalicCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextSmallCapsCommand {
|
||||||
|
TextSmallCapsCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextTeletypeCommand {
|
||||||
|
TextTeletypeCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextMediumCommand {
|
||||||
|
TextMediumCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextSansSerifCommand {
|
||||||
|
TextSansSerifCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextSuperscriptCommand {
|
||||||
|
TextSuperscriptCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
TextSubscriptCommand {
|
||||||
|
TextSubscriptCtrlSeq TextArgument
|
||||||
|
} |
|
||||||
|
StrikeOutCommand {
|
||||||
|
TextStrikeOutCtrlSeq ArgumentType
|
||||||
|
} |
|
||||||
|
EmphasisCommand {
|
||||||
|
EmphasisCtrlSeq ArgumentType
|
||||||
|
} |
|
||||||
|
UnderlineCommand {
|
||||||
|
UnderlineCtrlSeq ArgumentType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ import {
|
|||||||
hasMoreArguments,
|
hasMoreArguments,
|
||||||
hasMoreArgumentsOrOptionals,
|
hasMoreArgumentsOrOptionals,
|
||||||
endOfArgumentsAndOptionals,
|
endOfArgumentsAndOptionals,
|
||||||
|
TextBoldCtrlSeq,
|
||||||
|
TextItalicCtrlSeq,
|
||||||
|
TextSmallCapsCtrlSeq,
|
||||||
|
TextTeletypeCtrlSeq,
|
||||||
|
TextMediumCtrlSeq,
|
||||||
|
TextSansSerifCtrlSeq,
|
||||||
|
TextSuperscriptCtrlSeq,
|
||||||
|
TextSubscriptCtrlSeq,
|
||||||
|
TextStrikeOutCtrlSeq,
|
||||||
|
EmphasisCtrlSeq,
|
||||||
|
UnderlineCtrlSeq,
|
||||||
} from './latex.terms.mjs'
|
} from './latex.terms.mjs'
|
||||||
|
|
||||||
const MAX_ARGUMENT_LOOKAHEAD = 100
|
const MAX_ARGUMENT_LOOKAHEAD = 100
|
||||||
@@ -579,6 +590,17 @@ const otherKnowncommands = {
|
|||||||
'\\bottomrule': BottomRuleCtrlSeq,
|
'\\bottomrule': BottomRuleCtrlSeq,
|
||||||
'\\multicolumn': MultiColumnCtrlSeq,
|
'\\multicolumn': MultiColumnCtrlSeq,
|
||||||
'\\parbox': ParBoxCtrlSeq,
|
'\\parbox': ParBoxCtrlSeq,
|
||||||
|
'\\textbf': TextBoldCtrlSeq,
|
||||||
|
'\\textit': TextItalicCtrlSeq,
|
||||||
|
'\\textsc': TextSmallCapsCtrlSeq,
|
||||||
|
'\\texttt': TextTeletypeCtrlSeq,
|
||||||
|
'\\textmd': TextMediumCtrlSeq,
|
||||||
|
'\\textsf': TextSansSerifCtrlSeq,
|
||||||
|
'\\textsuperscript': TextSuperscriptCtrlSeq,
|
||||||
|
'\\textsubscript': TextSubscriptCtrlSeq,
|
||||||
|
'\\sout': TextStrikeOutCtrlSeq,
|
||||||
|
'\\emph': EmphasisCtrlSeq,
|
||||||
|
'\\underline': UnderlineCtrlSeq,
|
||||||
}
|
}
|
||||||
// specializer for control sequences
|
// specializer for control sequences
|
||||||
// return new tokens for specific control sequences
|
// return new tokens for specific control sequences
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
||||||
import { SyntaxNode, Tree } from '@lezer/common'
|
import { SyntaxNode, Tree } from '@lezer/common'
|
||||||
import { isUnknownCommandWithName } from './common'
|
|
||||||
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
|
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
|
||||||
|
|
||||||
const HUNDRED_MS = 100
|
const HUNDRED_MS = 100
|
||||||
@@ -125,14 +124,15 @@ export const lastAncestorAtEndPosition = (
|
|||||||
node: SyntaxNode | null | undefined,
|
node: SyntaxNode | null | undefined,
|
||||||
to: number
|
to: number
|
||||||
): SyntaxNode | null => {
|
): SyntaxNode | null => {
|
||||||
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
|
let lastAncestor: SyntaxNode | null = null
|
||||||
if (ancestor.parent?.to === to) {
|
for (
|
||||||
continue
|
let ancestor = node;
|
||||||
} else if (ancestor.to === to) {
|
ancestor && ancestor.to === to;
|
||||||
return ancestor
|
ancestor = ancestor.parent
|
||||||
}
|
) {
|
||||||
|
lastAncestor = ancestor
|
||||||
}
|
}
|
||||||
return null
|
return lastAncestor
|
||||||
}
|
}
|
||||||
|
|
||||||
export const descendantsOfNodeWithType = (
|
export const descendantsOfNodeWithType = (
|
||||||
@@ -226,37 +226,6 @@ export const commonAncestor = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const withinFormattingCommand = (state: EditorState) => {
|
|
||||||
const tree = syntaxTree(state)
|
|
||||||
|
|
||||||
return (command: string): boolean => {
|
|
||||||
const isFormattedText = (range: SelectionRange): boolean => {
|
|
||||||
const nodeLeft = tree.resolveInner(range.from, -1)
|
|
||||||
const formattingCommandLeft = matchingAncestor(nodeLeft, node =>
|
|
||||||
isUnknownCommandWithName(node, command, state)
|
|
||||||
)
|
|
||||||
if (!formattingCommandLeft) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need to check the other end of the selection, and ensure that they
|
|
||||||
// share a common formatting command ancestor
|
|
||||||
const nodeRight = tree.resolveInner(range.to, 1)
|
|
||||||
const ancestor = commonAncestor(formattingCommandLeft, nodeRight)
|
|
||||||
if (!ancestor) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattingAncestor = matchingAncestor(ancestor, node =>
|
|
||||||
isUnknownCommandWithName(node, command, state)
|
|
||||||
)
|
|
||||||
return Boolean(formattingAncestor)
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.selection.ranges.every(isFormattedText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
|
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
|
||||||
|
|
||||||
export const listDepthForNode = (node: SyntaxNode) => {
|
export const listDepthForNode = (node: SyntaxNode) => {
|
||||||
|
|||||||
@@ -58,35 +58,6 @@ export const getOptionalArgumentText = (
|
|||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg')
|
const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg')
|
||||||
if (shortArgNode) {
|
if (shortArgNode) {
|
||||||
const shortArgNodeText = state.doc.sliceString(
|
return state.doc.sliceString(shortArgNode.from, shortArgNode.to)
|
||||||
shortArgNode.from,
|
|
||||||
shortArgNode.to
|
|
||||||
)
|
|
||||||
return shortArgNodeText
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveNodeAtPos = (
|
|
||||||
state: EditorState,
|
|
||||||
pos: number,
|
|
||||||
side?: -1 | 0 | 1
|
|
||||||
) => ensureSyntaxTree(state, pos, HUNDRED_MS)?.resolveInner(pos, side) ?? null
|
|
||||||
|
|
||||||
export const isUnknownCommandWithName = (
|
|
||||||
node: SyntaxNode,
|
|
||||||
command: string,
|
|
||||||
state: EditorState
|
|
||||||
): boolean => {
|
|
||||||
if (!node.type.is('UnknownCommand')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const commandNameNode = node.getChild('CtrlSeq')
|
|
||||||
if (!commandNameNode) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const commandName = state.doc.sliceString(
|
|
||||||
commandNameNode.from,
|
|
||||||
commandNameNode.to
|
|
||||||
)
|
|
||||||
return commandName === command
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { EditorState, SelectionRange } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import {
|
||||||
|
commonAncestor,
|
||||||
|
matchingAncestor,
|
||||||
|
} from '@/features/source-editor/utils/tree-operations/ancestors'
|
||||||
|
|
||||||
|
export type FormattingCommand = '\\textbf' | '\\textit'
|
||||||
|
export type FormattingNodeType = string | number
|
||||||
|
|
||||||
|
export const formattingCommandMap: Record<
|
||||||
|
FormattingCommand,
|
||||||
|
FormattingNodeType
|
||||||
|
> = {
|
||||||
|
'\\textbf': 'TextBoldCommand',
|
||||||
|
'\\textit': 'TextItalicCommand',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withinFormattingCommand = (state: EditorState) => {
|
||||||
|
const tree = syntaxTree(state)
|
||||||
|
|
||||||
|
return (command: FormattingCommand): boolean => {
|
||||||
|
const nodeType = formattingCommandMap[command]
|
||||||
|
|
||||||
|
const isFormattedText = (range: SelectionRange): boolean => {
|
||||||
|
const nodeLeft = tree.resolveInner(range.from, -1)
|
||||||
|
const formattingCommandLeft = matchingAncestor(nodeLeft, node =>
|
||||||
|
node.type.is(nodeType)
|
||||||
|
)
|
||||||
|
if (!formattingCommandLeft) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to check the other end of the selection, and ensure that they
|
||||||
|
// share a common formatting command ancestor
|
||||||
|
const nodeRight = tree.resolveInner(range.to, 1)
|
||||||
|
const ancestor = commonAncestor(formattingCommandLeft, nodeRight)
|
||||||
|
if (!ancestor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattingAncestor = matchingAncestor(ancestor, node =>
|
||||||
|
node.type.is(nodeType)
|
||||||
|
)
|
||||||
|
return Boolean(formattingAncestor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.selection.ranges.every(isFormattedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ export {
|
|||||||
iterateDescendantsOf,
|
iterateDescendantsOf,
|
||||||
previousSiblingIs,
|
previousSiblingIs,
|
||||||
nextSiblingIs,
|
nextSiblingIs,
|
||||||
isUnknownCommandWithName,
|
|
||||||
} from './tree-operations/common'
|
} from './tree-operations/common'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
+25
@@ -327,6 +327,31 @@ describe('<CodeMirrorEditor/> paste HTML in Visual mode', function () {
|
|||||||
cy.get('td i').should('have.length', 2)
|
cy.get('td i').should('have.length', 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles a pasted table with formatting markup', function () {
|
||||||
|
mountEditor()
|
||||||
|
|
||||||
|
const data =
|
||||||
|
'<table><tbody><tr>' +
|
||||||
|
'<td><b>foo</b></td>' +
|
||||||
|
'<td><i>bar</i></td>' +
|
||||||
|
'<td><b><i>baz</i></b></td>' +
|
||||||
|
'<td><i><b>buzz</b></i></td>' +
|
||||||
|
'<td><sup>up</sup></td>' +
|
||||||
|
'<td><sub>down</sub></td>' +
|
||||||
|
'</tr></tbody></table>'
|
||||||
|
|
||||||
|
const clipboardData = new DataTransfer()
|
||||||
|
clipboardData.setData('text/html', data)
|
||||||
|
cy.get('@content').trigger('paste', { clipboardData })
|
||||||
|
|
||||||
|
cy.get('@content').should('have.text', 'foobarbazbuzzupdown')
|
||||||
|
cy.findByText(/Sorry/).should('not.exist')
|
||||||
|
cy.get('td b').should('have.length', 3)
|
||||||
|
cy.get('td i').should('have.length', 3)
|
||||||
|
cy.get('td sup').should('have.length', 1)
|
||||||
|
cy.get('td sub').should('have.length', 1)
|
||||||
|
})
|
||||||
|
|
||||||
it('handles a pasted table with a caption', function () {
|
it('handles a pasted table with a caption', function () {
|
||||||
mountEditor()
|
mountEditor()
|
||||||
|
|
||||||
|
|||||||
+12
-14
@@ -124,20 +124,17 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
|
|||||||
cy.get('.cm-content').should('have.text', ' testtest')
|
cy.get('.cm-content').should('have.text', ' testtest')
|
||||||
})
|
})
|
||||||
|
|
||||||
forEach(['textbf', 'textit', 'underline']).it(
|
forEach(['textbf', 'textit']).it('handles \\%s text', function (command) {
|
||||||
'handles \\%s text',
|
cy.get('@first-line').type(`\\${command}{`)
|
||||||
function (command) {
|
cy.get('@first-line').should('have.text', `{}`)
|
||||||
cy.get('@first-line').type(`\\${command}{`)
|
cy.get('@first-line').type('{rightArrow} ')
|
||||||
cy.get('@first-line').should('have.text', `{}`)
|
cy.get('@first-line').should('have.text', '{} ')
|
||||||
cy.get('@first-line').type('{rightArrow} ')
|
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
|
||||||
cy.get('@first-line').should('have.text', '{} ')
|
cy.get('@first-line').should('have.text', '{test text}')
|
||||||
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
|
cy.get('@first-line').type('{rightArrow} foo')
|
||||||
cy.get('@first-line').should('have.text', '{test text}')
|
cy.get('@first-line').should('have.text', 'test text foo') // no braces
|
||||||
cy.get('@first-line').type('{rightArrow} foo')
|
cy.get('@first-line').find(`.ol-cm-command-${command}`)
|
||||||
cy.get('@first-line').should('have.text', 'test text foo') // no braces
|
})
|
||||||
cy.get('@first-line').find(`.ol-cm-command-${command}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
forEach([
|
forEach([
|
||||||
'part',
|
'part',
|
||||||
@@ -171,6 +168,7 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
|
|||||||
'textsuperscript',
|
'textsuperscript',
|
||||||
'sout',
|
'sout',
|
||||||
'emph',
|
'emph',
|
||||||
|
'underline',
|
||||||
'url',
|
'url',
|
||||||
'caption',
|
'caption',
|
||||||
]).it('handles \\%s text', function (command) {
|
]).it('handles \\%s text', function (command) {
|
||||||
|
|||||||
Reference in New Issue
Block a user