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