diff --git a/services/web/frontend/js/features/source-editor/commands/ranges.ts b/services/web/frontend/js/features/source-editor/commands/ranges.ts index a98e81d0ad..fe533e452b 100644 --- a/services/web/frontend/js/features/source-editor/commands/ranges.ts +++ b/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) } diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx index 2fb546e428..9ce7f283c7 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +++ b/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) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts index 7e33049a52..75125e9f99 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts +++ b/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( diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts index 46d74886c0..939f0cbc8e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts +++ b/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([ + [ + '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([ + ['\\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() + } + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts b/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts index f13738a5d6..f80cbc43ab 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts +++ b/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 = diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts index 45c20ba6c1..a7ef5a6c72 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts @@ -52,6 +52,19 @@ const typeMap: Record = { 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({ diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar index f4cad45736..426b80387f 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar +++ b/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 { (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 } } diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs index e46c43ac51..552f814a53 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs +++ b/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 diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts index 487019a377..e89fcd9069 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts +++ b/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) => { diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts index c607eee85f..1355057d9e 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts +++ b/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 -} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/formatting.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/formatting.ts new file mode 100644 index 0000000000..dffdd246f5 --- /dev/null +++ b/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) + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-query.ts b/services/web/frontend/js/features/source-editor/utils/tree-query.ts index 267ac4b4b3..ef7a28fbc1 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-query.ts +++ b/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 { diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx index a6b5ff54fe..c268686fff 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -327,6 +327,31 @@ describe(' 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 = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
foobarbazbuzzupdown
' + + 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() diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 047b9f21b1..274494d84c 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -124,20 +124,17 @@ describe(' 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(' in Visual mode', function () { 'textsuperscript', 'sout', 'emph', + 'underline', 'url', 'caption', ]).it('handles \\%s text', function (command) {