diff --git a/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts b/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts index f43715af60..1d436a2efe 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts @@ -1,11 +1,9 @@ import { useEffect, useState } from 'react' -import { EditorState } from '@codemirror/state' -import { SyntaxNode } from '@lezer/common' -import { syntaxTree } from '@codemirror/language' import { useProjectContext } from '@/shared/context/project-context' import { debugConsole } from '@/utils/debugging' import { CommandDefinition } from '../utils/command-definitions/command-definition' import { getCommandDefinitionIndexer } from '../utils/command-definitions/command-definition-indexer' +import { commandNameAtPos } from '../utils/command-definitions/command-name-at-pos' import { useCodeMirrorStateContext } from '../components/codemirror-context' import { contextMenuStateField } from '../extensions/context-menu' @@ -71,24 +69,3 @@ export function useContextMenuCommandDefinition() { const commandDefinition = useCommandDefinition(commandName, tooltip) return { commandName, commandDefinition } } - -/** - * Resolve the control sequence (e.g. `\mycmd`) at a document position, or null - * if the position is not on a command. Walks up from the innermost node to the - * nearest node carrying a control-sequence token, mirroring the command-usage - * logic in tree-operations/commands.ts. - */ -function commandNameAtPos(state: EditorState, pos: number): string | null { - const tree = syntaxTree(state) - // Bias right (as resolveCommandNode does) so a click on the command's first - // character resolves into it rather than the preceding token. - let node: SyntaxNode | null = tree.resolveInner(pos, 1) - for (; node; node = node.parent) { - const ctrlSeq = node.getChild('$CtrlSeq') - if (ctrlSeq && !ctrlSeq.type.is('$CtrlSym')) { - const name = state.sliceDoc(ctrlSeq.from, ctrlSeq.to) - return name.length > 0 ? name : null - } - } - return null -} diff --git a/services/web/frontend/js/features/source-editor/utils/command-definitions/command-name-at-pos.ts b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-name-at-pos.ts new file mode 100644 index 0000000000..8be91a7273 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/command-definitions/command-name-at-pos.ts @@ -0,0 +1,28 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode } from '@lezer/common' +import { syntaxTree } from '@codemirror/language' + +/** + * Resolve the control sequence (e.g. `\mycmd`) at a document position, or null + * if the position is not on a command. Walks up from the innermost node to the + * nearest control-sequence node, mirroring the command-usage + * logic in tree-operations/commands.ts. Control symbols (e.g. `\\`, `\%`) are + * excluded: they are also in the `$CtrlSeq` group, but are never user-defined + * commands. + */ +export function commandNameAtPos( + state: EditorState, + pos: number +): string | null { + const tree = syntaxTree(state) + // Bias right (as resolveCommandNode does) so a click on the command's first + // character resolves into it rather than the preceding token. + let node: SyntaxNode | null = tree.resolveInner(pos, 1) + for (; node; node = node.parent) { + if (node.type.is('$CtrlSeq') && !node.type.is('$CtrlSym')) { + const name = state.sliceDoc(node.from, node.to) + return name.length > 0 ? name : null + } + } + return null +} diff --git a/services/web/test/frontend/features/source-editor/utils/command-definitions/command-name-at-pos.test.ts b/services/web/test/frontend/features/source-editor/utils/command-definitions/command-name-at-pos.test.ts new file mode 100644 index 0000000000..b4c3e611fc --- /dev/null +++ b/services/web/test/frontend/features/source-editor/utils/command-definitions/command-name-at-pos.test.ts @@ -0,0 +1,47 @@ +import { LanguageSupport } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { expect } from 'chai' +import { commandNameAtPos } from '@/features/source-editor/utils/command-definitions/command-name-at-pos' +import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language' + +const latex = new LanguageSupport(LaTeXLanguage) + +const makeState = (doc: string): EditorState => + EditorState.create({ doc, extensions: [latex] }) + +describe('commandNameAtPos', function () { + it('resolves the command name when the position is on the command', function () { + const doc = 'before \\mycmd{arg} after' + const state = makeState(doc) + const from = doc.indexOf('\\mycmd') + expect(commandNameAtPos(state, from)).to.equal('\\mycmd') + expect(commandNameAtPos(state, from + 3)).to.equal('\\mycmd') + }) + + it('resolves a known command name', function () { + const doc = 'some \\textbf{bold} text' + const state = makeState(doc) + expect(commandNameAtPos(state, doc.indexOf('\\textbf'))).to.equal( + '\\textbf' + ) + }) + + it('returns null for a control symbol', function () { + const doc = 'linebreak \\\\ and \\% percent' + const state = makeState(doc) + expect(commandNameAtPos(state, doc.indexOf('\\\\'))).to.equal(null) + expect(commandNameAtPos(state, doc.indexOf('\\%'))).to.equal(null) + }) + + it('returns null on plain text', function () { + const doc = 'plain text \\mycmd{arg}' + const state = makeState(doc) + expect(commandNameAtPos(state, 0)).to.equal(null) + }) + + it("returns null inside a command's argument", function () { + const doc = '\\mycmd{argument}' + const state = makeState(doc) + expect(commandNameAtPos(state, doc.indexOf('argument') + 3)).to.equal(null) + }) +})