Files
overleaf-cep/services/web/frontend/js/features/source-editor/hooks/use-command-definitions.ts
T
Alf EatonandClaude Opus 4.8 f0b8e4af11 Add "Jump to command definition" to the editor context menu (#35116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GitOrigin-RevId: 5fcaef2004495a5e71bc083cf8fdc2bffce579d5
2026-09-17 11:47:09 +00:00

95 lines
3.6 KiB
TypeScript

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 { useCodeMirrorStateContext } from '../components/codemirror-context'
import { contextMenuStateField } from '../extensions/context-menu'
/**
* Look up where `name` is defined in the project, by parsing the snapshot in a
* worker. Intended to be used while the editor context menu is open: the lookup
* runs off the main thread, so opening the menu is never blocked, and the item
* is disabled until the worker responds.
*
* Pass `name` null when the menu was not opened on a command, to avoid
* refreshing the snapshot (a project-history flush) and parsing on every
* right-click. `lookupKey` should change on each open (e.g. the tooltip object)
* so the lookup reruns even if the menu component stays mounted across reopens.
*/
function useCommandDefinition(name: string | null, lookupKey: unknown) {
const { projectSnapshot } = useProjectContext()
const [definition, setDefinition] = useState<CommandDefinition | null>(null)
useEffect(() => {
// Reset while the new lookup runs so a stale definition isn't shown.
setDefinition(null)
if (!name) {
return
}
let cancelled = false
;(async () => {
try {
await projectSnapshot.refresh()
if (cancelled) {
return
}
const found = await getCommandDefinitionIndexer().findDefinition(
projectSnapshot,
name
)
if (!cancelled) {
setDefinition(found)
}
} catch (error) {
debugConsole.error('Failed to find command definition', error)
}
})()
return () => {
cancelled = true
}
}, [projectSnapshot, name, lookupKey])
return definition
}
/**
* Resolve the command the editor context menu was opened on and its project-wide
* definition (if any). Returns nulls when the menu was not opened on a command;
* the lookup only runs when the menu was opened on a command.
*/
export function useContextMenuCommandDefinition() {
const state = useCodeMirrorStateContext()
// The tooltip is a new object on each open, so use it as the lookup key.
const tooltip = state.field(contextMenuStateField, false)?.tooltip ?? null
const pos = tooltip?.pos ?? null
const commandName = pos != null ? commandNameAtPos(state, pos) : null
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
}