Files
overleaf-cep/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts

127 lines
4.2 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
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 },
])
)