From aeb803c77ea822bdf158fb90d45f426edf5bc1ff Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Tue, 31 Mar 2026 23:06:05 +0000 Subject: [PATCH] Reference picker --- services/web/config/settings.defaults.js | 22 +- .../web/frontend/extracted-translations.json | 5 +- .../web/frontend/stylesheets/pages/all.scss | 1 + services/web/locales/en.json | 5 +- .../reference-picker-controller.tsx | 93 +++++ .../components/reference-picker-modal.tsx | 338 ++++++++++++++++++ .../extensions/reference-picker-keybinding.ts | 123 +++++++ .../enhanced-reference-index.ts | 167 +++++++++ .../frontend/styles/reference-picker.scss | 127 +++++++ .../web/modules/reference-picker/index.mjs | 2 + services/web/package.json | 2 +- .../codemirror-editor-autocomplete.spec.tsx | 178 +++++++++ 12 files changed, 1057 insertions(+), 6 deletions(-) create mode 100644 services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx create mode 100644 services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx create mode 100644 services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts create mode 100644 services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts create mode 100644 services/web/modules/reference-picker/frontend/styles/reference-picker.scss create mode 100644 services/web/modules/reference-picker/index.mjs diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index ff813a7051..908eae5a21 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -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', diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 75e3baff9f..e630f5c052 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "" -} +} \ No newline at end of file diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index ea4efe87b9..0a29d04ff2 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -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'; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 48c07a571e..6419f8caf4 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.", "zotero_upgrade_prompt_title": "Cite from Zotero" -} +} \ No newline at end of file diff --git a/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx b/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx new file mode 100644 index 0000000000..b881e8ad6b --- /dev/null +++ b/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx @@ -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(null) + const [to, setTo] = useState(null) + const [braceFrom, setBraceFrom] = useState(null) + const [braceTo, setBraceTo] = useState(null) + const [initialKeys, setInitialKeys] = useState([]) + + 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 ( + + ) +} diff --git a/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx b/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx new file mode 100644 index 0000000000..aa74e01b8a --- /dev/null +++ b/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx @@ -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([]) + + const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([]) + const [selectedFields, setSelectedFields] = + useState(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([]) + + // 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('search') + const [focusedIndex, setFocusedIndex] = useState(null) + const searchRef = useRef(null) + const footerRef = useRef(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) => { + 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 ( + + + + {t('references_picker_title')} + + + +
+ setQuery(e.target.value)} + autoFocus + ref={searchRef} + className="form-control" + data-testid="reference-picker-search" + /> + +
+ {SEARCH_FIELD_OPTIONS.map(s => ( + + ))} +
+ +
+ {selectedKeys.map(key => ( + toggleKey(key) }}> + {key} + + ))} +
+ +
+ {results.length === 0 ? ( +
+ {t('references_picker_empty_hint')} +
+ ) : ( + 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 ( + + ) + }) + )} +
+
+
+ +
+ + {t('cancel')} + + + {t('insert')} + +
+
+
+ ) +} diff --git a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts new file mode 100644 index 0000000000..7e4c560526 --- /dev/null +++ b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts @@ -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 }, + ]) + ) diff --git a/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts b/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts new file mode 100644 index 0000000000..8469dd37ff --- /dev/null +++ b/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts @@ -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> = new Map() + entryIndex: Map = new Map() + fuse: Fuse | null = null + + updateIndex({ updates, deletes }: Changes): Set { + 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() + 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 = { + 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 { + 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[] + + 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 + } +} diff --git a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss b/services/web/modules/reference-picker/frontend/styles/reference-picker.scss new file mode 100644 index 0000000000..3d86f9bb66 --- /dev/null +++ b/services/web/modules/reference-picker/frontend/styles/reference-picker.scss @@ -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); + } +} diff --git a/services/web/modules/reference-picker/index.mjs b/services/web/modules/reference-picker/index.mjs new file mode 100644 index 0000000000..24ea521213 --- /dev/null +++ b/services/web/modules/reference-picker/index.mjs @@ -0,0 +1,2 @@ +const ReferencePickerModule = {} +export default ReferencePickerModule diff --git a/services/web/package.json b/services/web/package.json index 1a8957c909..556b769306 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -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", diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index 62c063c2e8..f4e39334e5 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -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 = ({ children }) => { + return ( + + {children} + + ) + } + + cy.mount( + + + + + + ) + + 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 = ({ children }) => { + return ( + + {children} + + ) + } + + cy.mount( + + + + + + ) + + 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 = ({ children }) => { + return ( + + {children} + + ) + } + + cy.mount( + + + + + + ) + + 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[] = [ {