Просмотр исходного кода

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

GitOrigin-RevId: f69cd323992c80de3f0a458a637fa8f160017076
Alf Eaton 2 лет назад
Родитель
Сommit
603ff28df0

+ 67 - 65
services/web/frontend/js/features/source-editor/commands/ranges.ts

@@ -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 - 1
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx

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

+ 31 - 41
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

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

+ 113 - 100
services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts

@@ -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 - 2
services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts

@@ -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 =

+ 13 - 0
services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts

@@ -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({

+ 45 - 1
services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar

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

+ 22 - 0
services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs

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

+ 8 - 39
services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts

@@ -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) => {

+ 1 - 30
services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts

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

+ 50 - 0
services/web/frontend/js/features/source-editor/utils/tree-operations/formatting.ts

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

+ 0 - 1
services/web/frontend/js/features/source-editor/utils/tree-query.ts

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

+ 25 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx

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

+ 12 - 14
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

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