import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { OLModal, OLModalBody, OLModalFooter, 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 { useTranslation } from 'react-i18next' import type { Bib2JsonEntry } from '@/features/ide-react/references/types' type FocusArea = 'search' | 'list' | 'footer' function highlight(text: string, tokens: string[]) { if (!text || tokens.length === 0) return text const parts: React.ReactNode[] = [text] let globalIndex = 0 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({ 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 pendingInitialKeysRef = useRef([]) const requestIdRef = useRef(0) const searchFnRef = useRef(searchLocalReferences) useEffect(() => { searchFnRef.current = searchLocalReferences }, [searchLocalReferences]) const tokens = useMemo( () => query.toLowerCase().trim().split(/\s+/).filter(Boolean), [query] ) useEffect(() => { if (!show) return requestIdRef.current++ setQuery('') setSelectedKeys([]) setResults([]) pendingInitialKeysRef.current = [...initialKeys] const requestId = ++requestIdRef.current const perform = async () => { const r = await searchFnRef.current('') if (requestId !== requestIdRef.current) return setResults(r.hits) 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() }, [show, initialKeys]) 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 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 => { 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(() => { 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) { const k = filteredKeys[focusedIndex] if (k) toggleKey(k) } } else if (event.key === 'Enter') { event.preventDefault() handleApply() } } if (event.key === 'Tab') { const isShift = event.shiftKey event.preventDefault() 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, filteredKeys, focusedIndex, toggleKey, handleApply] ) useEffect(() => { if (focusArea === 'list' && focusedIndex !== null) { const el = document.getElementById(`reference-picker-item-${focusedIndex}`) if (el) el.focus() } }, [focusArea, focusedIndex]) return ( {t('references_picker_title')} setQuery(e.target.value)} placeholder={t('search_references')} prepend={} ref={searchRef} /> {selectedKeys.map((key, idx) => ( removeOne(key) }} > {key} ))} {results.map((hit, index) => { const key = hit._source.EntryKey const { title = '', author = '', year = '', journal = '' } = hit._source.Fields ?? {} return ( { setFocusArea('list') setFocusedIndex(index) toggleKey(key) }} className={`search-result-hit ${ focusedIndex === index ? 'focused' : '' } ${selectedKeys.includes(key) ? 'selected-search-result-hit' : ''}`} > {highlight(title, tokens)} {highlight(key, tokens)} {[ highlight(author, tokens), highlight(journal, tokens), highlight(year, tokens), ] .filter(Boolean) .reduce((acc, val, i) => i === 0 ? [val] : acc.concat('\u00A0—\u00A0', val), [])} ) })} {t('cancel')} {initialKeys.length ? t('search_replace') : t('insert')} ) }