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

GitOrigin-RevId: f69cd323992c80de3f0a458a637fa8f160017076
This commit is contained in:
Alf Eaton
2024-08-02 08:06:11 +00:00
committed by Copybot
parent 37e897260c
commit 603ff28df0
14 changed files with 389 additions and 294 deletions
@@ -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,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)
@@ -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(
@@ -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 {
@@ -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()
@@ -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) {