From 89883b0efeb1060b5f59e3dc049cb23b5af4f2b4 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 6 Apr 2026 18:10:34 +0200 Subject: [PATCH] Reference picker: normal search instead of fuzzy; overleafying modal --- services/web/config/settings.defaults.js | 2 +- .../web/frontend/stylesheets/modules/all.scss | 1 + .../web/frontend/stylesheets/pages/all.scss | 1 - services/web/locales/ru.json | 1 + .../reference-picker-controller.tsx | 55 ++- .../components/reference-picker-modal.tsx | 459 ++++++++++-------- .../extensions/reference-picker-keybinding.ts | 31 +- .../advanced-reference-index.ts | 97 ++++ .../enhanced-reference-index.ts | 167 ------- .../frontend/styles/reference-picker.scss | 145 ++---- services/web/package.json | 4 +- 11 files changed, 425 insertions(+), 538 deletions(-) create mode 100644 services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts delete mode 100644 services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 80fc514b00..5c4b3174bc 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1130,7 +1130,7 @@ module.exports = { referenceIndices: [ Path.resolve( __dirname, - '../modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts' + '../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts' ), ], railEntries: [], diff --git a/services/web/frontend/stylesheets/modules/all.scss b/services/web/frontend/stylesheets/modules/all.scss index 23462389e6..3245355191 100644 --- a/services/web/frontend/stylesheets/modules/all.scss +++ b/services/web/frontend/stylesheets/modules/all.scss @@ -15,3 +15,4 @@ @import 'admin-tools/manage-projects-page'; @import 'admin-tools/manage-users-page'; @import 'admin-tools/pagination-dark'; +@import '../../../modules/reference-picker/frontend/styles/reference-picker'; diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index 90c1c7fbe0..3e6c9adcbe 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -35,7 +35,6 @@ @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/editor-tour-tooltip'; @import 'editor/new-editor-promo-modal'; diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index ae43e4c625..f320187825 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -431,6 +431,7 @@ "search_bib_files": "Поиск по автору, названию, году", "search_projects": "Поиск по проектам", "search_references": "Поиск .bib файлов в проекте", + "search_replace": "Заменить", "security": "Безопасность", "select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя", "select_all_projects": "Выбрать все проекты", 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 index b881e8ad6b..6fed369ac2 100644 --- a/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx +++ b/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx @@ -17,29 +17,33 @@ export default function ReferencePickerController() { 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 + ', ' - } - } - } + const doc = view.state.doc + const bFrom = braceFrom ?? from + const bTo = braceTo ?? to - view.dispatch({ changes: { from, to, insert } }) + let insertCore = selectedKeys.join(', ') + + // Expand over spaces/tabs and commas around selection + let start = from + let end = to + while (start > bFrom && /[ \t,]/.test(doc.sliceString(start - 1, start))) start-- + while (end < bTo && /[ \t,]/.test(doc.sliceString(end, end + 1))) end++ + + let prefix = '' + let suffix = '' + if (start > bFrom && doc.sliceString(start - 1, start) !== '\n') prefix = ', ' + if (end < bTo && insertCore) suffix = ', ' + + const insert = prefix + insertCore + suffix + + // Compute cursor position after inserted tokens + const cursorPos = start + prefix.length + insertCore.length + (end === bTo ? 0 : 1) + + view.dispatch({ + changes: { from: start, to: end, insert }, + selection: { anchor: cursorPos }, + }) view.focus() }, [view, from, to, braceFrom, braceTo] @@ -50,12 +54,11 @@ export default function ReferencePickerController() { 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 }, + changes: { from: pos, to: pos, insert: insertText } }) const newFrom = pos + insertText.indexOf('{') + 1 setFrom(newFrom) @@ -75,11 +78,7 @@ export default function ReferencePickerController() { } window.addEventListener('reference:openPicker', handler as EventListener) - return () => - window.removeEventListener( - '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 index aa74e01b8a..700b888380 100644 --- a/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx +++ b/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx @@ -6,38 +6,55 @@ import { OLModalHeader, OLModalTitle, } from '@/shared/components/ol/ol-modal' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' import OLButton from '@/shared/components/ol/ol-button' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLTag from '@/shared/components/ol/ol-tag' +import MaterialIcon from '@/shared/components/material-icon' 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 +function highlight(text: string, tokens: string[]) { + if (!text || tokens.length === 0) return text -const DEFAULT_FIELDS = ['author', 'title', 'year', 'journal', 'EntryKey'] + const parts: React.ReactNode[] = [text] + let globalIndex = 0 -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 + tokens.forEach(token => { + const next: React.ReactNode[] = [] + parts.forEach(part => { + if (typeof part !== 'string') { + next.push(part) + return + } + + const lower = part.toLowerCase() + const t = token.toLowerCase() + let start = 0 + let idx + + while ((idx = lower.indexOf(t, start)) !== -1) { + if (idx > start) next.push(part.slice(start, idx)) + next.push( + + {part.slice(idx, idx + t.length)} + + ) + start = idx + t.length + } + + if (start < part.length) next.push(part.slice(start)) + }) + parts.splice(0, parts.length, ...next) + }) + + return parts } export default function ReferencePickerModal({ @@ -56,78 +73,97 @@ export default function ReferencePickerModal({ 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([]) + const requestIdRef = useRef(0) + const searchFnRef = useRef(searchLocalReferences) - // 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]) + searchFnRef.current = searchLocalReferences + }, [searchLocalReferences]) + + const tokens = useMemo( + () => query.toLowerCase().trim().split(/\s+/).filter(Boolean), + [query] + ) useEffect(() => { if (!show) return - let cancelled = false + + requestIdRef.current++ + + setQuery('') + setSelectedKeys([]) + setResults([]) + + pendingInitialKeysRef.current = [...initialKeys] + + const requestId = ++requestIdRef.current const perform = async () => { - // The module's enhanced index handles empty query as "list all" - const r = await searchLocalReferences(query.trim() || '') - if (cancelled) return + const r = await searchFnRef.current('') - // 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) - } + if (requestId !== requestIdRef.current) return + + 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) - } + 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]) + }, [show, initialKeys]) - const filteredKeys = useMemo( - () => results.map(r => r._source.EntryKey), - [results] - ) + useEffect(() => { + if (!show) return + + const requestId = ++requestIdRef.current + + const perform = async () => { + const r = await searchFnRef.current(query.trim() || '') + + if (requestId !== requestIdRef.current) return + + setResults(r.hits) + } + + perform() + }, [query]) + + const filteredKeys = results.map(r => r._source.EntryKey) const [focusArea, setFocusArea] = useState('search') const [focusedIndex, setFocusedIndex] = useState(null) const searchRef = useRef(null) - const footerRef = useRef(null) + const cancelButtonRef = useRef(null) + const insertButtonRef = useRef(null) + + const removeOne = useCallback((key: string) => { + setSelectedKeys(prev => { + const index = prev.indexOf(key) + if (index === -1) return prev + return [...prev.slice(0, index), ...prev.slice(index + 1)] + }) + }, []) + + const addOne = useCallback((key: string) => { + setSelectedKeys(prev => [...prev, key]) + }, []) const toggleKey = useCallback((key: string) => { - setSelectedKeys(prev => - prev.includes(key) ? prev.filter(x => x !== key) : [...prev, key] - ) + setSelectedKeys(prev => { + const index = prev.indexOf(key) + if (index !== -1) { + // remove one occurrence + return [...prev.slice(0, index), ...prev.slice(index + 1)] + } + return [...prev, key] + }) }, []) const handleApply = useCallback(() => { @@ -164,8 +200,9 @@ export default function ReferencePickerModal({ ) } else if (event.key === ' ') { event.preventDefault() - if (focusedIndex != null && filteredKeys[focusedIndex]) { - toggleKey(filteredKeys[focusedIndex]) + if (focusedIndex !== null) { + const k = filteredKeys[focusedIndex] + if (k) toggleKey(k) } } else if (event.key === 'Enter') { event.preventDefault() @@ -174,165 +211,171 @@ export default function ReferencePickerModal({ } if (event.key === 'Tab') { + const isShift = event.shiftKey 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 - ) + event.stopPropagation() + + if (!isShift) { + if (focusArea === 'search') { + setFocusArea('list') + setFocusedIndex(0) + return + } + if (focusArea === 'list') { + setFocusArea('footer') + setFocusedIndex(null) + cancelButtonRef.current?.focus() + return + } + if (focusArea === 'footer') { + if (document.activeElement === cancelButtonRef.current) { + insertButtonRef.current?.focus() + return + } else { + setFocusArea('search') + setFocusedIndex(null) + searchRef.current?.focus() + return + } + } + } + + if (isShift) { + if (focusArea === 'search') { + setFocusArea('footer') + setFocusedIndex(null) + insertButtonRef.current?.focus() + return + } + if (focusArea === 'list') { + setFocusArea('search') + setFocusedIndex(null) + searchRef.current?.focus() + return + } + if (focusArea === 'footer') { + if (document.activeElement === insertButtonRef.current) { + cancelButtonRef.current?.focus() + return + } else { + setFocusArea('list') + setFocusedIndex(prev => prev ?? 0) + return + } + } } } }, - [focusArea, focusedIndex, filteredKeys, toggleKey, handleApply] + [focusArea, filteredKeys, focusedIndex, 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') + const el = document.getElementById(`reference-picker-item-${focusedIndex}`) + if (el) el.focus() } }, [focusArea, focusedIndex]) - const toggleField = useCallback((value: string) => { - setSelectedFields(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ) - }, []) - return ( - - {t('references_picker_title')} - + {t('references_picker_title')} - -
- setQuery(e.target.value)} - autoFocus - ref={searchRef} - className="form-control" - data-testid="reference-picker-search" - /> - -
- {SEARCH_FIELD_OPTIONS.map(s => ( -
) } 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 index 7e4c560526..2b58e8a888 100644 --- a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts +++ b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts @@ -13,7 +13,7 @@ function parseTokens( text: string ): { value: string; start: number; end: number }[] { const tokens: { value: string; start: number; end: number }[] = [] - const re = /[^\s,]+/g + 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 }) @@ -49,18 +49,10 @@ function openPickerIfInCite(view: EditorView): boolean { 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) - - // 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 @@ -92,20 +84,31 @@ function openPickerIfInCite(view: EditorView): boolean { }) ) } else { - // No selection — insert at cursor position + 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: pos, - insertTo: pos, + insertFrom: insertPos, + insertTo: insertPos, selectedTokens: [], }, }) ) } - return true } } catch { diff --git a/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts b/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts new file mode 100644 index 0000000000..2e361e40a8 --- /dev/null +++ b/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts @@ -0,0 +1,97 @@ +import { ReferenceIndex } from '@/features/ide-react/references/reference-index' +import type { + Changes, + Bib2JsonEntry, + AdvancedReferenceSearchResult, +} from '@/features/ide-react/references/types' + +export default class AdvancedReferenceIndex extends ReferenceIndex { + fileIndex: Map> = new Map() + entryIndex: Map = new Map() + + 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)) + ) + + return this.keys + } + + async search(query: string): Promise { + const q = (query || '').toLowerCase().trim() + + // Empty query: return all entries + if (!q) { + return this.list() + } + + const tokens = q.split(/\s+/).filter(Boolean) + + const hits: { _source: Bib2JsonEntry }[] = [] + + for (const entry of this.entryIndex.values()) { + + const match = tokens.every(token => + this.matchesAnyField(entry, token) + ) + + if (match) { + hits.push({ _source: entry }) + } + } + + return { hits } + } + + private list(limit: number = 0): AdvancedReferenceSearchResult { + const results: { _source: Bib2JsonEntry }[] = [] + for (const entry of this.entryIndex.values()) { + if (limit && results.length >= limit) break + results.push({ _source: entry }) + } + return { hits: results } + } + + private matchesAnyField(entry: Bib2JsonEntry, token: string): boolean { + const t = token.toLowerCase() + const f = entry.Fields + + return ( + entry.EntryKey.toLowerCase().includes(t) || + f.author.toLowerCase().includes(t) || + f.journal.toLowerCase().includes(t) || + f.title.toLowerCase().includes(t) || + f.date.toLowerCase().includes(t) || + f.year.toLowerCase().includes(t) + ) + } +} 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 deleted file mode 100644 index 8469dd37ff..0000000000 --- a/services/web/modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 index 3d86f9bb66..3b1aaf659a 100644 --- a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss +++ b/services/web/modules/reference-picker/frontend/styles/reference-picker.scss @@ -1,127 +1,38 @@ -.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 { +.references-search-modal { + .selected-key-tag { + position: relative; display: flex; flex-wrap: wrap; gap: var(--spacing-02); align-items: center; - max-height: 72px; + min-height: 30px; + max-height: 58px; overflow-y: auto; - } - - .search-selectors { - display: flex; - gap: var(--spacing-02); - align-items: center; font-size: var(--font-size-02); + margin-top: -10px; + margin-bottom: 8px; + box-shadow: 0 1px 0 var(--ds-color-neutral-100); + padding-bottom: 6px; + + .badge-tag { + background-color:var(--ds-color-green-50) !important; + } } - .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); + .search-results { + .search-results-scroll-container { + max-height: calc(100vh - 330px); + overflow-y: auto; + } + .search-result-hit { + &:focus { + background: var(--ds-color-yellow-50); + outline: none; + } + } + .found-token { + color: var(--ds-color-red-600); + font-weight: bold; + } } } diff --git a/services/web/package.json b/services/web/package.json index 1d03b64cff..2f58539770 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -79,6 +79,7 @@ "last 1 year", "safari > 14" ], + "dependencies": { "@ai-sdk/mcp": "^1.0.0", "@ai-sdk/openai": "^3.0.0", @@ -139,7 +140,6 @@ "express": "4.22.1", "file-type": "^21.0.0", "focus-trap-react": "^11.0.4", - "fuse.js": "^7.0.0", "globby": "^5.0.0", "helmet": "^6.0.1", "https-proxy-agent": "^7.0.6", @@ -423,4 +423,4 @@ "yup": "^0.32.11", "zustand": "^5.0.1" } -} \ No newline at end of file +}