mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-24 01:29:35 +02:00
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
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 },
|
|
])
|
|
)
|