mirror of
https://github.com/overleaf/overleaf.git
synced 2025-03-14 03:54:48 +00:00
Add text formatting commands to the LaTeX grammar (#19607)
GitOrigin-RevId: f69cd323992c80de3f0a458a637fa8f160017076
This commit is contained in:
parent
37e897260c
commit
603ff28df0
14 changed files with 389 additions and 294 deletions
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
previousSiblingIs,
|
||||
nextSiblingIs,
|
||||
isUnknownCommandWithName,
|
||||
} from './tree-operations/common'
|
||||
|
||||
export {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue