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

@@ -1057,7 +1057,12 @@ module.exports = {
sourceEditorToolbarComponents: [],
sourceEditorToolbarEndButtons: [],
rootContextProviders: [],
mainEditorLayoutModals: [],
mainEditorLayoutModals: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
),
],
mainEditorLayoutPanels: [],
langFeedbackLinkingWidgets: [],
labsExperiments: [],
@@ -1104,7 +1109,12 @@ module.exports = {
rollingBuildsUpdatedAlert: [],
offlineModeToolbarButtons: [],
settingsEntries: [],
autoCompleteExtensions: [],
autoCompleteExtensions: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts'
),
],
sectionTitleGenerators: [],
toastGenerators: [
Path.resolve(
@@ -1139,7 +1149,12 @@ module.exports = {
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
errorLogsComponents: [],
referenceIndices: [],
referenceIndices: [
Path.resolve(
__dirname,
'../modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts'
),
],
railEntries: [],
railPopovers: [],
},
@@ -1150,6 +1165,7 @@ module.exports = {
'server-ce-scripts',
'sandboxed-compiles',
'symbol-palette',
'reference-picker',
'track-changes',
'authentication/ldap',
'authentication/saml',

View File

@@ -1612,10 +1612,13 @@
"reference_managers": "",
"reference_search": "",
"reference_search_setting": "",
"reference_search_results": "",
"reference_search_settings": "",
"reference_search_style": "",
"reference_sync": "",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "",
"references_picker_empty_hint": "",
"references_picker_title": "",
"refresh": "",
"refresh_page_after_linking_dropbox": "",
"refresh_page_after_starting_free_trial": "",
@@ -2595,4 +2598,4 @@
"zotero_sync_description": "",
"zotero_upgrade_prompt_content": "",
"zotero_upgrade_prompt_title": ""
}
}

View File

@@ -35,6 +35,7 @@
@import 'editor/table-generator-column-width-modal';
@import 'editor/math-preview';
@import 'editor/references-search';
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
@import 'editor/editor-survey';
@import 'editor/themed-tooltip';
@import 'editor/new-editor-promo-modal';

View File

@@ -2106,7 +2106,10 @@
"reference_search_settings": "Reference search settings",
"reference_search_style": "Reference search style",
"reference_sync": "Reference manager sync",
"reference_search_results": "Reference search results",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "References from these libraries will be included in your reference search results.",
"references_picker_empty_hint": "Start typing to search references",
"references_picker_title": "Select references",
"refresh": "Refresh",
"refresh_page_after_linking_dropbox": "Please refresh this page after linking your account to Dropbox.",
"refresh_page_after_starting_free_trial": "Please refresh this page after starting your free trial.",
@@ -3266,4 +3269,4 @@
"zotero_sync_description": "With the Zotero integration you can import your references from Zotero into your __appName__ projects.",
"zotero_upgrade_prompt_content": "Link your Zotero account to search and add your references from Zotero directly in your project—theyll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.",
"zotero_upgrade_prompt_title": "Cite from Zotero"
}
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

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

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
const ReferencePickerModule = {}
export default ReferencePickerModule

View File

@@ -74,7 +74,6 @@
"last 1 year",
"safari > 14"
],
"dependencies": {
"@ai-sdk/google-vertex": "^4.0.113",
"@ai-sdk/mcp": "patch:@ai-sdk/mcp@npm%3A1.0.37#~/.yarn/patches/@ai-sdk-mcp-npm-1.0.37-8cd89b8972.patch",
@@ -139,6 +138,7 @@
"file-type": "^21.3.4",
"focus-trap-react": "^11.0.4",
"form-data": "^4.0.5",
"fuse.js": "^7.0.0",
"globby": "^5.0.0",
"helmet": "^6.0.1",
"https-proxy-agent": "^7.0.6",

View File

@@ -433,6 +433,184 @@ describe('autocomplete', { scrollBehavior: false }, function () {
cy.get('@line').contains('\\cite{ref-2, ref-3}')
})
it('reference picker avoids preselecting non-bibkey text in unclosed cite argument', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
const ReferencesProvider: FC<React.PropsWithChildren> = ({ children }) => {
return (
<ReferencesContext.Provider
value={{
referenceKeys: new Set(['ref-1', 'ref-2', 'ref-3']),
indexAllReferences: cy.stub(),
searchLocalReferences() {
return Promise.resolve({ hits: [] })
},
}}
>
{children}
</ReferencesContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ ReferencesProvider }}
rootFolder={rootFolder as any}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.get('.cm-line').eq(16).as('line').click()
// type malformed cite with natural text; the picker should not treat that phrase
// as a selected reference key.
cy.get('@line').type('here is some text \\cite{ here the text continues.')
cy.get('@line').type('{ctrl+ }')
cy.get('[data-testid="reference-picker-title"]').should('not.exist')
cy.get('[data-testid="reference-picker-selected-chips"]').should(
'not.exist'
)
})
it('reference picker in cite ignores trailing words that are not bibkeys', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
const ReferencesProvider: FC<React.PropsWithChildren> = ({ children }) => {
return (
<ReferencesContext.Provider
value={{
referenceKeys: new Set(['sadassadfsdf', 'existing-key']),
indexAllReferences: cy.stub(),
searchLocalReferences() {
return Promise.resolve({ hits: [] })
},
}}
>
{children}
</ReferencesContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ ReferencesProvider }}
rootFolder={rootFolder as any}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.get('.cm-line').eq(16).as('line').click()
cy.get('@line').type('\\cite{sadassadfsdf still allows a ctrl+Space.')
cy.get('@line').type('{ctrl+ }')
cy.get('[data-testid="reference-picker-title"]').should('not.exist')
cy.get('[data-testid="reference-picker-selected-chips"]').should(
'not.exist'
)
})
it('reference picker does not open when cite is unclosed', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
const ReferencesProvider: FC<React.PropsWithChildren> = ({ children }) => {
return (
<ReferencesContext.Provider
value={{
referenceKeys: new Set(['sadassadfsdf', 'existing-key']),
indexAllReferences: cy.stub(),
searchLocalReferences() {
return Promise.resolve({ hits: [] })
},
}}
>
{children}
</ReferencesContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ ReferencesProvider }}
rootFolder={rootFolder as any}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.get('.cm-line').eq(16).as('line').click()
cy.get('@line').type('\\cite{sadasadsd')
cy.get('@line').type('{ctrl+ }')
cy.get('[data-testid="reference-picker-title"]').should('not.exist')
cy.get('[data-testid="reference-picker-selected-chips"]').should(
'not.exist'
)
})
it('autocomplete stops after space after command', function () {
const rootFolder: Folder[] = [
{