Reference picker

This commit is contained in:
David Rotermund
2026-03-31 23:06:05 +00:00
committed by yu-i-i
parent a7e2f19978
commit aeb803c77e
12 changed files with 1057 additions and 6 deletions

View File

@@ -0,0 +1,123 @@
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
if (hasSelection) {
const selectedText = view.state.doc.sliceString(selFrom, selTo)
// Don't open modal if the selection contains unpaired braces
const opens = (selectedText.match(/{/g) || []).length
const closes = (selectedText.match(/}/g) || []).length
if (opens !== closes) {
return startCompletion(view)
}
// Parse the full cite content into tokens for partial-key expansion
const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
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 {
// No selection — insert at cursor position
window.dispatchEvent(
new CustomEvent('reference:openPicker', {
detail: {
braceFrom,
braceTo,
insertFrom: pos,
insertTo: pos,
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 },
])
)