Add text formatting commands to the LaTeX grammar (#19607)

GitOrigin-RevId: f69cd323992c80de3f0a458a637fa8f160017076
This commit is contained in:
Alf Eaton 2024-08-02 08:59:11 +01:00 committed by Copybot
parent 37e897260c
commit 603ff28df0
14 changed files with 389 additions and 294 deletions

View file

@ -7,11 +7,13 @@ import {
syntaxTree,
} from '@codemirror/language'
import { SyntaxNode } from '@lezer/common'
import {
ancestorOfNodeWithType,
isUnknownCommandWithName,
} from '../utils/tree-query'
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 =
(
@ -208,13 +210,20 @@ export const duplicateSelection = (view: EditorView) => {
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 = null
let node: SyntaxNode | undefined | null
if (typeof position === 'number') {
node = tree?.resolveInner(position, assoc)?.parent
@ -226,13 +235,10 @@ function getParentNode(
node = position?.parent
}
while (
['LongArg', 'TextArgument', 'OpenBrace', 'CloseBrace'].includes(
node?.type.name || ''
)
) {
node = node!.parent
while (node && skipParentNodeTypes.includes(node.type.name)) {
node = node.parent
}
return node || undefined
}
@ -274,18 +280,17 @@ function validateReplacement(expected: string, actual: string) {
function getWrappingAncestor(
node: SyntaxNode,
command: string,
state: EditorState
nodeType: FormattingNodeType
): SyntaxNode | null {
for (
let ancestor: SyntaxNode | null = node;
ancestor;
ancestor = ancestor.parent
) {
if (isUnknownCommandWithName(ancestor, command, state)) {
if (ancestor.type.is(nodeType)) {
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
return null
}
@ -294,7 +299,7 @@ function getWrappingAncestor(
}
function adjustRangeIfNeeded(
command: string,
nodeType: FormattingNodeType,
range: SelectionRange,
state: EditorState
) {
@ -311,50 +316,51 @@ function adjustRangeIfNeeded(
const nodeLeft = tree.resolveInner(range.from, 1)
const nodeRight = tree.resolveInner(range.to, -1)
const parentLeft = getWrappingAncestor(nodeLeft, command, state)
const parentRight = getWrappingAncestor(nodeRight, command, state)
const parentLeft = getWrappingAncestor(nodeLeft, nodeType)
const parentRight = getWrappingAncestor(nodeRight, nodeType)
const parent = getParentNode(nodeLeft, state)
if (parent?.type.is('UnknownCommand') && spansWholeArgument(parent, range)) {
if (
parent?.type.is('$ToggleTextFormattingCommand') &&
spansWholeArgument(parent, range)
) {
return bubbleUpRange(
command,
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
range,
state
nodeType,
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
range
)
}
if (!parentLeft) {
// We're not trying to unbold, so don't bother adjusting range
return bubbleUpRange(
command,
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
range,
state
nodeType,
ancestorOfNodeWithType(nodeLeft, '$ToggleTextFormattingCommand'),
range
)
}
if (nodeLeft.type.is('CtrlSeq') && range.from === range.to) {
const command = nodeLeft.parent?.parent
if (!command) {
if (nodeLeft.type.is('$CtrlSeq') && range.from === range.to) {
const commandNode = nodeLeft.parent?.parent?.parent
if (!commandNode) {
return range
}
return EditorSelection.cursor(command.from)
return EditorSelection.cursor(commandNode.from)
}
let { from, to } = range
if (nodeLeft.type.is('CtrlSeq')) {
if (nodeLeft.type.is('$CtrlSeq')) {
from = nodeLeft.to + 1
}
if (nodeLeft.type.is('OpenBrace')) {
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
// UnknownCommand)
// $ToggleTextFormattingCommand)
if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
to = nodeRight.from
}
return bubbleUpRange(command, parentLeft, moveRange(range, from, to), state)
return bubbleUpRange(nodeType, parentLeft, moveRange(range, from, to))
}
function spansWholeArgument(
@ -362,28 +368,26 @@ function spansWholeArgument(
range: SelectionRange
): boolean {
const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
const res = Boolean(
return Boolean(
argument && argument.from === range.from && argument.to === range.to
)
return res
}
function bubbleUpRange(
command: string,
nodeType: string | number,
node: SyntaxNode | null,
range: SelectionRange,
state: EditorState
range: SelectionRange
) {
let currentRange = range
for (
let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
node,
'UnknownCommand'
'$ToggleTextFormattingCommand'
);
spansWholeArgument(ancestorCommand, currentRange);
ancestorCommand = ancestorOfNodeWithType(
ancestorCommand.parent,
'UnknownCommand'
'$ToggleTextFormattingCommand'
)
) {
if (!ancestorCommand) {
@ -394,7 +398,7 @@ function bubbleUpRange(
ancestorCommand.from,
ancestorCommand.to
)
if (isUnknownCommandWithName(ancestorCommand, command, state)) {
if (ancestorCommand.type.is(nodeType)) {
const argumentNode = ancestorCommand
.getChild('TextArgument')
?.getChild('LongArg')
@ -408,7 +412,9 @@ function bubbleUpRange(
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.
* In the following examples, the selection range is marked within <>
@ -443,7 +449,7 @@ export function toggleRanges(command: string) {
}
view.dispatch(
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 ancestorAtStartOfRange = getParentNode(
@ -470,25 +476,22 @@ export function toggleRanges(command: string) {
) {
// But handle the exception of case 8
const ancestorAtStartIsWrappingCommand =
ancestorAtStartOfRange &&
isUnknownCommandWithName(
ancestorAtStartOfRange,
command,
view.state
)
ancestorAtStartOfRange?.type.is(nodeType)
const ancestorAtEndIsWrappingCommand =
ancestorAtEndOfRange &&
isUnknownCommandWithName(ancestorAtEndOfRange, command, view.state)
ancestorAtEndOfRange && ancestorAtEndOfRange.type.is(nodeType)
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtEndIsWrappingCommand &&
ancestorAtStartOfRange?.parent?.parent &&
ancestorAtEndOfRange?.parent?.parent
ancestorAtStartOfRange?.parent?.parent?.parent &&
ancestorAtEndOfRange?.parent?.parent?.parent
) {
// Test for case 8
const nextAncestorAtStartOfRange =
ancestorAtStartOfRange.parent.parent
const nextAncestorAtEndOfRange = ancestorAtEndOfRange.parent.parent
ancestorAtStartOfRange.parent.parent.parent
const nextAncestorAtEndOfRange =
ancestorAtEndOfRange.parent.parent.parent
if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
// Join the two ranges
@ -539,7 +542,8 @@ export function toggleRanges(command: string) {
if (
ancestorAtEndIsWrappingCommand &&
ancestorAtEndOfRange.parent?.parent === ancestorAtStartOfRange
ancestorAtEndOfRange.parent?.parent?.parent ===
ancestorAtStartOfRange
) {
// Extend to the left. Case 10
const contentUpToCommand = view.state.sliceDoc(
@ -582,7 +586,9 @@ export function toggleRanges(command: string) {
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtStartOfRange.parent?.parent === ancestorAtEndOfRange
ancestorAtStartOfRange &&
ancestorAtStartOfRange.parent?.parent?.parent ===
ancestorAtEndOfRange
) {
// Extend to the right. Case 9
const contentAfterCommand = view.state.sliceDoc(
@ -635,15 +641,11 @@ export function toggleRanges(command: string) {
range.empty &&
ancestor &&
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
// \textbf, then we just wrap it in a range. Case 3.
if (
isCursorBeforeAncestor ||
!ancestor ||
!isUnknownCommandWithName(ancestor, command, view.state)
) {
if (isCursorBeforeAncestor || !ancestor?.type.is(nodeType)) {
return wrapRangeInCommand(view.state, range, command)
}

View file

@ -1,7 +1,6 @@
import { FC, memo } from 'react'
import { EditorState } from '@codemirror/state'
import { useEditorContext } from '../../../../shared/context/editor-context'
import { withinFormattingCommand } from '../../utils/tree-operations/ancestors'
import { ToolbarButton } from './toolbar-button'
import { redo, undo } from '@codemirror/commands'
import * as commands from '../../extensions/toolbar/commands'
@ -11,6 +10,7 @@ import { InsertFigureDropdown } from './insert-figure-dropdown'
import { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
import { TableInserterDropdown } from './table-inserter-dropdown'
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
const isMac = /Mac/.test(window.navigator?.platform)

View file

@ -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')) {
// a command that's not defined separately by the grammar
const commandNode = nodeRef.node
const commandNameNode = commandNode.getChild('$CtrlSeq')
const textArgumentNode = commandNode.getChild('TextArgument')
if (commandNameNode) {
const commandName = state.doc
@ -1050,46 +1077,9 @@ export const atomicDecorations = (options: Options) => {
.trim()
if (commandName.length > 0) {
if (
// markup that can be toggled using toolbar buttons/keyboard shortcuts
['\\textbf', '\\textit', '\\underline'].includes(commandName)
) {
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') {
const textArgumentNode = commandNode.getChild('TextArgument')
if (commandName === '\\keywords') {
if (shouldDecorate(state, nodeRef)) {
// command name and opening brace
decorations.push(

View file

@ -2,59 +2,49 @@ import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character'
const isUnknownCommandWithName = (
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 = {
type Markup = {
elementType: keyof HTMLElementTagNameMap
className?: string
}
type MarkupMapping = {
[command: string]: NodeMapping
}
const MARKUP_COMMANDS: MarkupMapping = {
'\\textit': {
elementType: 'i',
},
'\\textbf': {
elementType: 'b',
},
'\\emph': {
elementType: 'em',
},
'\\texttt': {
elementType: 'span',
className: 'ol-cm-command-texttt',
},
'\\textsc': {
elementType: 'span',
className: 'ol-cm-command-textsc',
},
}
const textFormattingMarkupMap = new Map<string, Markup>([
[
'TextBoldCommand', // \\textbf
{ elementType: 'b' },
],
[
'TextItalicCommand', // \\textit
{ elementType: 'i' },
],
[
'TextSmallCapsCommand', // \\textsc
{ elementType: 'span', className: 'ol-cm-command-textsc' },
],
[
'TextTeletypeCommand', // \\texttt
{ elementType: 'span', className: 'ol-cm-command-texttt' },
],
[
'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.
@ -62,35 +52,44 @@ const MARKUP_COMMANDS: MarkupMapping = {
* function if you wish to typeset math content.
* @param node The syntax node containing the text to be typeset
* @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(
node: SyntaxNode,
element: HTMLElement,
state: EditorState | ((from: number, to: number) => string)
getText: EditorState | ((from: number, to: number) => string)
) {
let getText: (from: number, to: number) => string
if (typeof state === 'function') {
getText = state
} else {
getText = state!.sliceDoc.bind(state!)
if (getText instanceof EditorState) {
getText = getText.sliceDoc.bind(getText)
}
// If we're a TextArgument node, we should skip the braces
const argument = node.getChild('LongArg')
if (argument) {
node = argument
}
const ancestorStack = [element]
const ancestor = () => ancestorStack[ancestorStack.length - 1]
const popAncestor = () => ancestorStack.pop()!
const pushAncestor = (x: HTMLElement) => ancestorStack.push(x)
const pushAncestor = (element: HTMLElement) => ancestorStack.push(element)
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(
function enter(childNodeRef) {
const childNode = childNodeRef.node
if (from < childNode.from) {
ancestor().append(
document.createTextNode(getText(from, childNode.from))
@ -98,57 +97,43 @@ export function typesetNodeIntoElement(
from = childNode.from
}
if (childNode.type.is('UnknownCommand')) {
const commandNameNode = childNode.getChild('CtrlSeq')
if (commandNameNode) {
const commandName = getText(commandNameNode.from, commandNameNode.to)
const mapping: NodeMapping | undefined = MARKUP_COMMANDS[commandName]
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
}
}
// commands defined in the grammar
const markup = textFormattingMarkupMap.get(childNode.type.name)
if (markup) {
addMarkup(markup, childNode)
return
}
if (isUnknownCommandWithName(childNode, '\\and', getText)) {
const spanElement = document.createElement('span')
spanElement.classList.add('ol-cm-command-and')
pushAncestor(spanElement)
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (
isUnknownCommandWithName(childNode, '\\corref', getText) ||
isUnknownCommandWithName(childNode, '\\fnref', getText) ||
isUnknownCommandWithName(childNode, '\\thanks', getText)
) {
// ignoring these commands
from = childNode.to
return false
} else if (childNode.type.is('LineBreak')) {
ancestor().appendChild(document.createElement('br'))
from = childNode.to
} else if (childNode.type.is('UnknownCommand')) {
const command = getText(childNode.from, childNode.to)
const symbol = COMMAND_SUBSTITUTIONS.get(command.trim())
if (symbol !== undefined) {
// commands not defined in the grammar
const commandName = unknownCommandName(childNode, getText)
if (commandName) {
const markup = markupMap.get(commandName)
if (markup) {
addMarkup(markup, childNode)
return
}
if (['\\corref', '\\fnref', '\\thanks'].includes(commandName)) {
// ignoring these commands
from = childNode.to
return false
}
const symbol = COMMAND_SUBSTITUTIONS.get(commandName)
if (symbol) {
ancestor().append(document.createTextNode(symbol))
from = childNode.to
return false
}
} else if (childNode.type.is('LineBreak')) {
ancestor().append(document.createElement('br'))
from = childNode.to
}
},
function leave(childNodeRef) {
const childNode = childNodeRef.node
const commandName = getUnknownCommandName(childNode, getText)
if (
(commandName && Boolean(MARKUP_COMMANDS[commandName])) ||
isUnknownCommandWithName(childNode, '\\and', getText)
) {
if (shouldHandleLeave(childNode, getText)) {
const typeSetElement = popAncestor()
ancestor().appendChild(typeSetElement)
const textArgument = childNode.getChild('TextArgument')
@ -159,9 +144,37 @@ export function typesetNodeIntoElement(
}
}
)
if (from < node.to) {
ancestor().append(document.createTextNode(getText(from, node.to)))
}
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()
}
}
}

View file

@ -1,5 +1,4 @@
import { FlatOutlineItem } from '../../utils/tree-query'
import { enterNode } from '../../utils/tree-operations/outline'
import { enterNode, FlatOutlineItem } from '../../utils/tree-operations/outline'
import { makeProjectionStateField } from '../../utils/projection-state-field'
export const documentOutline =

View file

@ -52,6 +52,19 @@ const typeMap: Record<string, string[]> = {
Input: ['$CommandTooltipCommand'],
Ref: ['$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({

View file

@ -95,7 +95,18 @@
MidRuleCtrlSeq,
BottomRuleCtrlSeq,
MultiColumnCtrlSeq,
ParBoxCtrlSeq
ParBoxCtrlSeq,
TextBoldCtrlSeq,
TextItalicCtrlSeq,
TextSmallCapsCtrlSeq,
TextTeletypeCtrlSeq,
TextMediumCtrlSeq,
TextSansSerifCtrlSeq,
TextSuperscriptCtrlSeq,
TextSubscriptCtrlSeq,
TextStrikeOutCtrlSeq,
EmphasisCtrlSeq,
UnderlineCtrlSeq
}
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
@ -358,6 +369,39 @@ KnownCommand<ArgumentType> {
(optionalWhitespace? OptionalArgument)*
ShortTextArgument
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
}
}

View file

@ -86,6 +86,17 @@ import {
hasMoreArguments,
hasMoreArgumentsOrOptionals,
endOfArgumentsAndOptionals,
TextBoldCtrlSeq,
TextItalicCtrlSeq,
TextSmallCapsCtrlSeq,
TextTeletypeCtrlSeq,
TextMediumCtrlSeq,
TextSansSerifCtrlSeq,
TextSuperscriptCtrlSeq,
TextSubscriptCtrlSeq,
TextStrikeOutCtrlSeq,
EmphasisCtrlSeq,
UnderlineCtrlSeq,
} from './latex.terms.mjs'
const MAX_ARGUMENT_LOOKAHEAD = 100
@ -579,6 +590,17 @@ const otherKnowncommands = {
'\\bottomrule': BottomRuleCtrlSeq,
'\\multicolumn': MultiColumnCtrlSeq,
'\\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
// return new tokens for specific control sequences

View file

@ -1,7 +1,6 @@
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { SyntaxNode, Tree } from '@lezer/common'
import { isUnknownCommandWithName } from './common'
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
const HUNDRED_MS = 100
@ -125,14 +124,15 @@ export const lastAncestorAtEndPosition = (
node: SyntaxNode | null | undefined,
to: number
): SyntaxNode | null => {
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
if (ancestor.parent?.to === to) {
continue
} else if (ancestor.to === to) {
return ancestor
}
let lastAncestor: SyntaxNode | null = null
for (
let ancestor = node;
ancestor && ancestor.to === to;
ancestor = ancestor.parent
) {
lastAncestor = ancestor
}
return null
return lastAncestor
}
export const descendantsOfNodeWithType = (
@ -226,37 +226,6 @@ export const commonAncestor = (
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 const listDepthForNode = (node: SyntaxNode) => {

View file

@ -58,35 +58,6 @@ export const getOptionalArgumentText = (
): string | undefined => {
const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg')
if (shortArgNode) {
const shortArgNodeText = state.doc.sliceString(
shortArgNode.from,
shortArgNode.to
)
return shortArgNodeText
return state.doc.sliceString(shortArgNode.from, shortArgNode.to)
}
}
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
}

View file

@ -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)
}
}

View file

@ -10,7 +10,6 @@ export {
iterateDescendantsOf,
previousSiblingIs,
nextSiblingIs,
isUnknownCommandWithName,
} from './tree-operations/common'
export {

View file

@ -327,6 +327,31 @@ describe('<CodeMirrorEditor/> paste HTML in Visual mode', function () {
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 () {
mountEditor()

View file

@ -124,20 +124,17 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
cy.get('.cm-content').should('have.text', ' testtest')
})
forEach(['textbf', 'textit', 'underline']).it(
'handles \\%s text',
function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', '{} ')
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
cy.get('@first-line').should('have.text', '{test text}')
cy.get('@first-line').type('{rightArrow} foo')
cy.get('@first-line').should('have.text', 'test text foo') // no braces
cy.get('@first-line').find(`.ol-cm-command-${command}`)
}
)
forEach(['textbf', 'textit']).it('handles \\%s text', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', '{} ')
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
cy.get('@first-line').should('have.text', '{test text}')
cy.get('@first-line').type('{rightArrow} foo')
cy.get('@first-line').should('have.text', 'test text foo') // no braces
cy.get('@first-line').find(`.ol-cm-command-${command}`)
})
forEach([
'part',
@ -171,6 +168,7 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
'textsuperscript',
'sout',
'emph',
'underline',
'url',
'caption',
]).it('handles \\%s text', function (command) {