import { keymap } from '@codemirror/view' import { Prec, Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { startCompletion } from '@codemirror/autocomplete' import { getBibkeyArgumentNode } from '@/features/source-editor/utils/tree-operations/ancestors' /** * Parse cite-argument text into non-separator tokens with their * start/end positions relative to the beginning of the text. * Separators are commas and whitespace (space, tab, newline). */ function parseTokens( text: string ): { value: string; start: number; end: number }[] { const tokens: { value: string; start: number; end: number }[] = [] const re = /[^\s,%]+/g let m while ((m = re.exec(text)) !== null) { tokens.push({ value: m[0], start: m.index, end: m.index + m[0].length }) } return tokens } function openPickerIfInCite(view: EditorView): boolean { try { const mainSel = view.state.selection.main const pos = mainSel.head const node = getBibkeyArgumentNode(view.state, pos) if (node) { // Verify the node has proper matching braces — the tree parser // may produce incorrect boundaries during error recovery (e.g. // unclosed \cite{ or nested braces like {\bf ...}). const openChar = view.state.doc.sliceString(node.from, node.from + 1) const closeChar = view.state.doc.sliceString(node.to - 1, node.to) if (openChar !== '{' || closeChar !== '}') { return startCompletion(view) } const braceFrom = node.from + 1 const braceTo = node.to - 1 // Cursor must be inside the braces (between braceFrom and braceTo inclusive) if (pos < braceFrom || pos > braceTo) { return startCompletion(view) } // Clamp selection to brace boundaries const selFrom = Math.max(braceFrom, mainSel.from) const selTo = Math.min(braceTo, mainSel.to) const hasSelection = selFrom < selTo const fullContent = view.state.doc.sliceString(braceFrom, braceTo) if (hasSelection) { const selectedText = view.state.doc.sliceString(selFrom, selTo) // Parse the full cite content into tokens for partial-key expansion const tokens = parseTokens(fullContent) // Relative selection offsets within the cite content const relSelFrom = selFrom - braceFrom const relSelTo = selTo - braceFrom // Find tokens overlapping the selection and expand boundaries let expandedFrom = relSelFrom let expandedTo = relSelTo const selectedTokenValues: string[] = [] for (const token of tokens) { if (token.end > relSelFrom && token.start < relSelTo) { expandedFrom = Math.min(expandedFrom, token.start) expandedTo = Math.max(expandedTo, token.end) selectedTokenValues.push(token.value) } } window.dispatchEvent( new CustomEvent('reference:openPicker', { detail: { braceFrom, braceTo, insertFrom: braceFrom + expandedFrom, insertTo: braceFrom + expandedTo, selectedTokens: selectedTokenValues, }, }) ) } else { let insertPos = pos // If the insert position is inside a token, move it forward const relPos = pos - braceFrom if (relPos > 0 && !/[\s,%]/.test(fullContent[relPos - 1])) { for (let j = relPos; j <= fullContent.length; j++) { const ch = fullContent[j] if (j === fullContent.length || /[\s,%]/.test(ch)) { insertPos = braceFrom + j break } } } window.dispatchEvent( new CustomEvent('reference:openPicker', { detail: { braceFrom, braceTo, insertFrom: insertPos, insertTo: insertPos, selectedTokens: [], }, }) ) } return true } } catch { // fall through to default completion } return startCompletion(view) } export const extension = (): Extension => Prec.highest( keymap.of([ { key: 'Ctrl-Space', run: openPickerIfInCite }, { key: 'Alt-Space', run: startCompletion }, ]) )