mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Reference picker
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import ReferencePickerModal from './reference-picker-modal'
|
||||
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
|
||||
|
||||
export default function ReferencePickerController() {
|
||||
const { view } = useEditorViewContext()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [from, setFrom] = useState<number | null>(null)
|
||||
const [to, setTo] = useState<number | null>(null)
|
||||
const [braceFrom, setBraceFrom] = useState<number | null>(null)
|
||||
const [braceTo, setBraceTo] = useState<number | null>(null)
|
||||
const [initialKeys, setInitialKeys] = useState<string[]>([])
|
||||
|
||||
const onClose = useCallback(() => setOpen(false), [])
|
||||
|
||||
const onApply = useCallback(
|
||||
(selectedKeys: string[]) => {
|
||||
if (!view || from == null || to == null) return
|
||||
let insert = selectedKeys.join(', ')
|
||||
|
||||
// Smart separator handling for cursor-only insertion (from === to)
|
||||
if (from === to && insert.length > 0) {
|
||||
const bFrom = braceFrom ?? from
|
||||
const bTo = braceTo ?? to
|
||||
// Add ", " before inserted keys if adjacent to existing content
|
||||
if (from > bFrom) {
|
||||
const charBefore = view.state.doc.sliceString(from - 1, from)
|
||||
if (!/[,\s]/.test(charBefore)) {
|
||||
insert = ', ' + insert
|
||||
}
|
||||
}
|
||||
// Add ", " after inserted keys if adjacent to existing content
|
||||
if (to < bTo) {
|
||||
const charAfter = view.state.doc.sliceString(to, to + 1)
|
||||
if (!/[,\s]/.test(charAfter)) {
|
||||
insert = insert + ', '
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch({ changes: { from, to, insert } })
|
||||
view.focus()
|
||||
},
|
||||
[view, from, to, braceFrom, braceTo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (evt: CustomEvent) => {
|
||||
const detail = evt.detail || {}
|
||||
|
||||
if (detail.insertFrom == null) {
|
||||
// Not inside a cite — insert \cite{} and open modal
|
||||
if (!view) return
|
||||
const pos = view.state.selection.main.head
|
||||
const insertText = '\\cite{}'
|
||||
view.dispatch({
|
||||
changes: { from: pos, to: pos, insert: insertText },
|
||||
})
|
||||
const newFrom = pos + insertText.indexOf('{') + 1
|
||||
setFrom(newFrom)
|
||||
setTo(newFrom)
|
||||
setBraceFrom(newFrom)
|
||||
setBraceTo(newFrom)
|
||||
setInitialKeys([])
|
||||
} else {
|
||||
setFrom(detail.insertFrom)
|
||||
setTo(detail.insertTo)
|
||||
setBraceFrom(detail.braceFrom ?? detail.insertFrom)
|
||||
setBraceTo(detail.braceTo ?? detail.insertTo)
|
||||
setInitialKeys(detail.selectedTokens || [])
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener('reference:openPicker', handler as EventListener)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'reference:openPicker',
|
||||
handler as EventListener
|
||||
)
|
||||
}, [view])
|
||||
|
||||
return (
|
||||
<ReferencePickerModal
|
||||
show={open}
|
||||
onClose={onClose}
|
||||
onApply={onApply}
|
||||
initialKeys={initialKeys}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||
import Tag from '@/shared/components/tag'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Bib2JsonEntry } from '@/features/ide-react/references/types'
|
||||
|
||||
type FocusArea = 'search' | 'list' | 'footer'
|
||||
|
||||
const SEARCH_FIELD_OPTIONS = [
|
||||
{ label: 'Author', value: 'author' },
|
||||
{ label: 'Title', value: 'title' },
|
||||
{ label: 'Year', value: 'year' },
|
||||
{ label: 'Journal', value: 'journal' },
|
||||
{ label: 'Key', value: 'EntryKey' },
|
||||
] as const
|
||||
|
||||
const DEFAULT_FIELDS = ['author', 'title', 'year', 'journal', 'EntryKey']
|
||||
|
||||
function matchesFields(
|
||||
entry: Bib2JsonEntry,
|
||||
query: string,
|
||||
fields: string[]
|
||||
): boolean {
|
||||
const q = query.toLowerCase()
|
||||
const noFilter = fields.length === 0
|
||||
if ((noFilter || fields.includes('EntryKey')) && entry.EntryKey.toLowerCase().includes(q)) return true
|
||||
const f = entry.Fields
|
||||
if ((noFilter || fields.includes('title')) && f.title?.toLowerCase().includes(q)) return true
|
||||
if ((noFilter || fields.includes('author')) && f.author?.toLowerCase().includes(q)) return true
|
||||
if ((noFilter || fields.includes('journal')) && f.journal?.toLowerCase().includes(q)) return true
|
||||
if ((noFilter || fields.includes('year')) && f.year?.toLowerCase().includes(q)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export default function ReferencePickerModal({
|
||||
show,
|
||||
onClose,
|
||||
onApply,
|
||||
initialKeys,
|
||||
}: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onApply: (selectedKeys: string[]) => void
|
||||
initialKeys: string[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { searchLocalReferences } = useReferencesContext()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||
|
||||
const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([])
|
||||
const [selectedFields, setSelectedFields] =
|
||||
useState<string[]>(DEFAULT_FIELDS)
|
||||
|
||||
// Ref holding initial key tokens that still need to be matched against
|
||||
// actual bib entries once the first search completes.
|
||||
const pendingInitialKeysRef = useRef<string[]>([])
|
||||
|
||||
// Reset state and load fresh entries every time the modal opens
|
||||
const [openCount, setOpenCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setQuery('')
|
||||
setSelectedKeys([])
|
||||
setResults([])
|
||||
pendingInitialKeysRef.current = [...initialKeys]
|
||||
setOpenCount(c => c + 1)
|
||||
}
|
||||
}, [show, initialKeys])
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
let cancelled = false
|
||||
|
||||
const perform = async () => {
|
||||
// The module's enhanced index handles empty query as "list all"
|
||||
const r = await searchLocalReferences(query.trim() || '')
|
||||
if (cancelled) return
|
||||
|
||||
// Apply field filtering client-side
|
||||
if (query.trim() && selectedFields.length > 0 && selectedFields.length < DEFAULT_FIELDS.length) {
|
||||
setResults(r.hits.filter(h => matchesFields(h._source, query.trim(), selectedFields)))
|
||||
} else {
|
||||
setResults(r.hits)
|
||||
}
|
||||
|
||||
// Match pending initial keys (from selection) against known bib keys
|
||||
if (pendingInitialKeysRef.current.length > 0) {
|
||||
const knownKeys = new Set(r.hits.map(h => h._source.EntryKey))
|
||||
const matched = pendingInitialKeysRef.current.filter(k =>
|
||||
knownKeys.has(k)
|
||||
)
|
||||
if (matched.length > 0) {
|
||||
setSelectedKeys(matched)
|
||||
}
|
||||
pendingInitialKeysRef.current = []
|
||||
}
|
||||
}
|
||||
|
||||
perform()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// openCount ensures a fresh search on every modal open
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, searchLocalReferences, selectedFields, show, openCount])
|
||||
|
||||
const filteredKeys = useMemo(
|
||||
() => results.map(r => r._source.EntryKey),
|
||||
[results]
|
||||
)
|
||||
|
||||
const [focusArea, setFocusArea] = useState<FocusArea>('search')
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
const footerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const toggleKey = useCallback((key: string) => {
|
||||
setSelectedKeys(prev =>
|
||||
prev.includes(key) ? prev.filter(x => x !== key) : [...prev, key]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
onApply(selectedKeys)
|
||||
onClose()
|
||||
}, [selectedKeys, onApply, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setTimeout(() => searchRef.current?.focus(), 0)
|
||||
setFocusArea('search')
|
||||
setFocusedIndex(null)
|
||||
}
|
||||
}, [show])
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (focusArea === 'search') {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setFocusArea('list')
|
||||
setFocusedIndex(0)
|
||||
}
|
||||
} else if (focusArea === 'list') {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setFocusedIndex(prev =>
|
||||
prev == null ? 0 : Math.min(filteredKeys.length - 1, prev + 1)
|
||||
)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setFocusedIndex(prev =>
|
||||
prev == null ? 0 : Math.max(0, prev - 1)
|
||||
)
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (focusedIndex != null && filteredKeys[focusedIndex]) {
|
||||
toggleKey(filteredKeys[focusedIndex])
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleApply()
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const areas: FocusArea[] = ['search', 'list', 'footer']
|
||||
const currentIdx = areas.indexOf(focusArea)
|
||||
const nextIdx = event.shiftKey
|
||||
? (currentIdx - 1 + areas.length) % areas.length
|
||||
: (currentIdx + 1) % areas.length
|
||||
const nextArea = areas[nextIdx]
|
||||
setFocusArea(nextArea)
|
||||
if (nextArea === 'list') {
|
||||
setFocusedIndex(0)
|
||||
} else if (nextArea === 'search') {
|
||||
setFocusedIndex(null)
|
||||
setTimeout(() => searchRef.current?.focus(), 0)
|
||||
} else {
|
||||
setFocusedIndex(null)
|
||||
setTimeout(
|
||||
() => footerRef.current?.querySelector('button')?.focus(),
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusArea, focusedIndex, filteredKeys, toggleKey, handleApply]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (focusArea === 'list' && focusedIndex !== null) {
|
||||
const el = document.getElementById(
|
||||
`reference-picker-item-${focusedIndex}`
|
||||
)
|
||||
if (el) {
|
||||
el.focus()
|
||||
searchRef.current?.setAttribute('aria-activedescendant', el.id)
|
||||
}
|
||||
}
|
||||
if (focusArea !== 'list') {
|
||||
searchRef.current?.removeAttribute('aria-activedescendant')
|
||||
}
|
||||
}, [focusArea, focusedIndex])
|
||||
|
||||
const toggleField = useCallback((value: string) => {
|
||||
setSelectedFields(prev =>
|
||||
prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={onClose} size="lg">
|
||||
<OLModalHeader>
|
||||
<OLModalTitle data-testid="reference-picker-title">
|
||||
{t('references_picker_title')}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
<div onKeyDown={onKeyDown} className="reference-picker">
|
||||
<input
|
||||
aria-label={t('search_references')}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
ref={searchRef}
|
||||
className="form-control"
|
||||
data-testid="reference-picker-search"
|
||||
/>
|
||||
|
||||
<div className="search-selectors">
|
||||
{SEARCH_FIELD_OPTIONS.map(s => (
|
||||
<label key={s.value} className="search-selector-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFields.includes(s.value)}
|
||||
onChange={() => toggleField(s.value)}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="selected-chips"
|
||||
data-testid="reference-picker-selected-chips"
|
||||
>
|
||||
{selectedKeys.map(key => (
|
||||
<Tag key={key} closeBtnProps={{ onClick: () => toggleKey(key) }}>
|
||||
{key}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t('reference_search_results')}
|
||||
id="reference-picker-list"
|
||||
data-testid="reference-picker-list"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<div
|
||||
className="reference-picker-empty"
|
||||
data-testid="reference-picker-empty"
|
||||
>
|
||||
{t('references_picker_empty_hint')}
|
||||
</div>
|
||||
) : (
|
||||
results.map((hit, index) => {
|
||||
const key = hit._source.EntryKey
|
||||
const { title = '', author = '', year = '', journal = '' } =
|
||||
hit._source.Fields ?? {}
|
||||
const meta = [
|
||||
author,
|
||||
author && year ? ` — ${year}` : year,
|
||||
journal ? ` · ${journal}` : '',
|
||||
].join('')
|
||||
|
||||
return (
|
||||
<label
|
||||
id={`reference-picker-item-${index}`}
|
||||
key={key}
|
||||
className={`d-block ${focusedIndex === index ? 'focused' : ''}`}
|
||||
role="option"
|
||||
aria-selected={selectedKeys.includes(key)}
|
||||
tabIndex={0}
|
||||
onClick={() => setFocusedIndex(index)}
|
||||
data-entry-key={key}
|
||||
data-testid={`reference-picker-item-${key}`}
|
||||
>
|
||||
<div className="hit-head">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.includes(key)}
|
||||
onChange={() => toggleKey(key)}
|
||||
/>
|
||||
<span className="hit-key">{key}</span>
|
||||
</div>
|
||||
<div className="hit-main">
|
||||
<span className="hit-title">{title}</span>
|
||||
<span className="hit-meta">{meta}</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<div ref={footerRef}>
|
||||
<OLButton variant="secondary" onClick={onClose}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleApply}
|
||||
data-testid="reference-picker-insert"
|
||||
>
|
||||
{t('insert')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
])
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ReferenceIndex } from '@/features/ide-react/references/reference-index'
|
||||
import type {
|
||||
Changes,
|
||||
Bib2JsonEntry,
|
||||
AdvancedReferenceSearchResult,
|
||||
} from '@/features/ide-react/references/types'
|
||||
import Fuse, { IFuseOptions, FuseResult } from 'fuse.js'
|
||||
|
||||
const MAX_RESULTS = 50
|
||||
|
||||
export default class EnhancedReferenceIndex extends ReferenceIndex {
|
||||
fileIndex: Map<string, Set<string>> = new Map()
|
||||
entryIndex: Map<string, Bib2JsonEntry> = new Map()
|
||||
fuse: Fuse<Bib2JsonEntry> | null = null
|
||||
|
||||
updateIndex({ updates, deletes }: Changes): Set<string> {
|
||||
for (const path of deletes) {
|
||||
const keys = this.fileIndex.get(path)
|
||||
if (keys) {
|
||||
for (const k of keys) {
|
||||
this.entryIndex.delete(k)
|
||||
}
|
||||
}
|
||||
this.fileIndex.delete(path)
|
||||
}
|
||||
|
||||
for (const { path, content } of updates) {
|
||||
const previous = this.fileIndex.get(path)
|
||||
if (previous) {
|
||||
for (const k of previous) {
|
||||
this.entryIndex.delete(k)
|
||||
}
|
||||
}
|
||||
const fileReferences = new Set<string>()
|
||||
const entries = this.parseEntries(content)
|
||||
for (const entry of entries) {
|
||||
fileReferences.add(entry.EntryKey)
|
||||
this.entryIndex.set(entry.EntryKey, entry)
|
||||
}
|
||||
this.fileIndex.set(path, fileReferences)
|
||||
}
|
||||
|
||||
this.keys = new Set(
|
||||
this.fileIndex.values().flatMap(entry => Array.from(entry))
|
||||
)
|
||||
this.rebuildFuseIndex()
|
||||
return this.keys
|
||||
}
|
||||
|
||||
private rebuildFuseIndex() {
|
||||
const data = Array.from(this.entryIndex.values())
|
||||
try {
|
||||
const options: IFuseOptions<Bib2JsonEntry> = {
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'EntryKey', weight: 0.8 },
|
||||
{ name: 'Fields.title', weight: 0.6 },
|
||||
{ name: 'Fields.author', weight: 0.5 },
|
||||
{ name: 'Fields.journal', weight: 0.3 },
|
||||
{ name: 'Fields.year', weight: 0.2 },
|
||||
],
|
||||
}
|
||||
this.fuse = new Fuse(data, options)
|
||||
} catch {
|
||||
this.fuse = null
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string): Promise<AdvancedReferenceSearchResult> {
|
||||
const q = (query || '').toLowerCase().trim()
|
||||
|
||||
// Empty query: return all entries (used by the picker to list references)
|
||||
if (!q) {
|
||||
return this.list(MAX_RESULTS)
|
||||
}
|
||||
|
||||
const tokens = q.split(/\s+/).filter(Boolean)
|
||||
const isSingleYear = tokens.length === 1 && /^[0-9]{4}$/.test(tokens[0])
|
||||
|
||||
// Exact year match shortcut
|
||||
if (isSingleYear) {
|
||||
const yearHits = this.substringScan(q, entry =>
|
||||
(entry.Fields.year || '').trim() === tokens[0]
|
||||
)
|
||||
if (yearHits.length > 0) return { hits: yearHits }
|
||||
}
|
||||
|
||||
// Short queries or no Fuse index: substring scan
|
||||
if (q.length <= 2 || !this.fuse) {
|
||||
return { hits: this.substringScan(q) }
|
||||
}
|
||||
|
||||
// Fuse-based fuzzy search
|
||||
try {
|
||||
return { hits: this.fuseSearch(q, tokens) }
|
||||
} catch {
|
||||
return { hits: this.substringScan(q) }
|
||||
}
|
||||
}
|
||||
|
||||
private list(limit: number): AdvancedReferenceSearchResult {
|
||||
const results: { _source: Bib2JsonEntry }[] = []
|
||||
for (const entry of this.entryIndex.values()) {
|
||||
if (results.length >= limit) break
|
||||
results.push({ _source: entry })
|
||||
}
|
||||
return { hits: results }
|
||||
}
|
||||
|
||||
private matchesAnyField(entry: Bib2JsonEntry, q: string): boolean {
|
||||
if (entry.EntryKey.toLowerCase().includes(q)) return true
|
||||
const f = entry.Fields
|
||||
if (f.title?.toLowerCase().includes(q)) return true
|
||||
if (f.author?.toLowerCase().includes(q)) return true
|
||||
if (f.journal?.toLowerCase().includes(q)) return true
|
||||
if (f.year?.toLowerCase().includes(q)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private substringScan(
|
||||
q: string,
|
||||
predicate?: (entry: Bib2JsonEntry) => boolean
|
||||
): { _source: Bib2JsonEntry }[] {
|
||||
const results: { _source: Bib2JsonEntry }[] = []
|
||||
for (const entry of this.entryIndex.values()) {
|
||||
if (results.length >= MAX_RESULTS) break
|
||||
if (predicate ? predicate(entry) : this.matchesAnyField(entry, q)) {
|
||||
results.push({ _source: entry })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private tokenSubstringScan(
|
||||
tokens: string[]
|
||||
): { _source: Bib2JsonEntry }[] {
|
||||
const results: { _source: Bib2JsonEntry }[] = []
|
||||
for (const entry of this.entryIndex.values()) {
|
||||
if (results.length >= MAX_RESULTS) break
|
||||
const match = tokens.every(t => this.matchesAnyField(entry, t))
|
||||
if (match) results.push({ _source: entry })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fuseSearch(
|
||||
q: string,
|
||||
tokens: string[]
|
||||
): { _source: Bib2JsonEntry }[] {
|
||||
const fuseResults = this.fuse!.search(q, { limit: MAX_RESULTS }) as FuseResult<Bib2JsonEntry>[]
|
||||
|
||||
let mapped = fuseResults.map(r => ({ _source: r.item }))
|
||||
|
||||
// Supplement with substring matches to catch anything Fuse missed
|
||||
const seen = new Set(mapped.map(m => m._source.EntryKey))
|
||||
const extra = this.tokenSubstringScan(tokens)
|
||||
.filter(r => !seen.has(r._source.EntryKey))
|
||||
if (extra.length) {
|
||||
mapped = [...mapped, ...extra]
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
.reference-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-02);
|
||||
|
||||
input[type='search'],
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
padding: var(--spacing-03);
|
||||
border: 1px solid var(--border-divider);
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: var(--font-size-03);
|
||||
}
|
||||
|
||||
.selected-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-02);
|
||||
align-items: center;
|
||||
max-height: 72px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-selectors {
|
||||
display: flex;
|
||||
gap: var(--spacing-02);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-02);
|
||||
}
|
||||
|
||||
.search-selector-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[role='listbox'] {
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border-divider);
|
||||
border-bottom: 1px solid var(--border-divider);
|
||||
}
|
||||
|
||||
label[role='option'] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-01);
|
||||
padding: var(--spacing-02);
|
||||
cursor: pointer;
|
||||
border-left: var(--spacing-02) solid transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
label[role='option'].focused,
|
||||
label[role='option']:focus {
|
||||
background: var(--bg-secondary);
|
||||
border-left-color: var(--content-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.03) inset;
|
||||
}
|
||||
|
||||
label[role='option'][aria-selected='true'] {
|
||||
font-weight: bold;
|
||||
color: var(--content-positive);
|
||||
}
|
||||
|
||||
label[role='option'] input[type='checkbox'] {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.hit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-01);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hit-head .hit-key {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
max-width: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.hit-key {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-02);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
color: var(--content-secondary);
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hit-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hit-title {
|
||||
font-size: var(--font-size-02);
|
||||
font-style: italic;
|
||||
color: var(--content-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hit-meta {
|
||||
font-size: var(--font-size-02);
|
||||
color: var(--content-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference-picker-empty {
|
||||
padding: var(--spacing-04);
|
||||
text-align: center;
|
||||
color: var(--content-secondary);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user