mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Reference picker: normal search instead of fuzzy; overleafying modal
This commit is contained in:
@@ -1130,7 +1130,7 @@ module.exports = {
|
|||||||
referenceIndices: [
|
referenceIndices: [
|
||||||
Path.resolve(
|
Path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../modules/reference-picker/frontend/reference-index/enhanced-reference-index.ts'
|
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts'
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
railEntries: [],
|
railEntries: [],
|
||||||
|
|||||||
@@ -15,3 +15,4 @@
|
|||||||
@import 'admin-tools/manage-projects-page';
|
@import 'admin-tools/manage-projects-page';
|
||||||
@import 'admin-tools/manage-users-page';
|
@import 'admin-tools/manage-users-page';
|
||||||
@import 'admin-tools/pagination-dark';
|
@import 'admin-tools/pagination-dark';
|
||||||
|
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
@import 'editor/table-generator-column-width-modal';
|
@import 'editor/table-generator-column-width-modal';
|
||||||
@import 'editor/math-preview';
|
@import 'editor/math-preview';
|
||||||
@import 'editor/references-search';
|
@import 'editor/references-search';
|
||||||
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
|
||||||
@import 'editor/editor-survey';
|
@import 'editor/editor-survey';
|
||||||
@import 'editor/editor-tour-tooltip';
|
@import 'editor/editor-tour-tooltip';
|
||||||
@import 'editor/new-editor-promo-modal';
|
@import 'editor/new-editor-promo-modal';
|
||||||
|
|||||||
@@ -431,6 +431,7 @@
|
|||||||
"search_bib_files": "Поиск по автору, названию, году",
|
"search_bib_files": "Поиск по автору, названию, году",
|
||||||
"search_projects": "Поиск по проектам",
|
"search_projects": "Поиск по проектам",
|
||||||
"search_references": "Поиск .bib файлов в проекте",
|
"search_references": "Поиск .bib файлов в проекте",
|
||||||
|
"search_replace": "Заменить",
|
||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
|
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
|
||||||
"select_all_projects": "Выбрать все проекты",
|
"select_all_projects": "Выбрать все проекты",
|
||||||
|
|||||||
@@ -17,29 +17,33 @@ export default function ReferencePickerController() {
|
|||||||
const onApply = useCallback(
|
const onApply = useCallback(
|
||||||
(selectedKeys: string[]) => {
|
(selectedKeys: string[]) => {
|
||||||
if (!view || from == null || to == null) return
|
if (!view || from == null || to == null) return
|
||||||
let insert = selectedKeys.join(', ')
|
|
||||||
|
|
||||||
// Smart separator handling for cursor-only insertion (from === to)
|
const doc = view.state.doc
|
||||||
if (from === to && insert.length > 0) {
|
const bFrom = braceFrom ?? from
|
||||||
const bFrom = braceFrom ?? from
|
const bTo = braceTo ?? to
|
||||||
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 } })
|
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.focus()
|
||||||
},
|
},
|
||||||
[view, from, to, braceFrom, braceTo]
|
[view, from, to, braceFrom, braceTo]
|
||||||
@@ -50,12 +54,11 @@ export default function ReferencePickerController() {
|
|||||||
const detail = evt.detail || {}
|
const detail = evt.detail || {}
|
||||||
|
|
||||||
if (detail.insertFrom == null) {
|
if (detail.insertFrom == null) {
|
||||||
// Not inside a cite — insert \cite{} and open modal
|
|
||||||
if (!view) return
|
if (!view) return
|
||||||
const pos = view.state.selection.main.head
|
const pos = view.state.selection.main.head
|
||||||
const insertText = '\\cite{}'
|
const insertText = '\\cite{}'
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: pos, to: pos, insert: insertText },
|
changes: { from: pos, to: pos, insert: insertText }
|
||||||
})
|
})
|
||||||
const newFrom = pos + insertText.indexOf('{') + 1
|
const newFrom = pos + insertText.indexOf('{') + 1
|
||||||
setFrom(newFrom)
|
setFrom(newFrom)
|
||||||
@@ -75,11 +78,7 @@ export default function ReferencePickerController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('reference:openPicker', handler as EventListener)
|
window.addEventListener('reference:openPicker', handler as EventListener)
|
||||||
return () =>
|
return () => window.removeEventListener('reference:openPicker', handler as EventListener)
|
||||||
window.removeEventListener(
|
|
||||||
'reference:openPicker',
|
|
||||||
handler as EventListener
|
|
||||||
)
|
|
||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,38 +6,55 @@ import {
|
|||||||
OLModalHeader,
|
OLModalHeader,
|
||||||
OLModalTitle,
|
OLModalTitle,
|
||||||
} from '@/shared/components/ol/ol-modal'
|
} 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 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 { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||||
import Tag from '@/shared/components/tag'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Bib2JsonEntry } from '@/features/ide-react/references/types'
|
import type { Bib2JsonEntry } from '@/features/ide-react/references/types'
|
||||||
|
|
||||||
type FocusArea = 'search' | 'list' | 'footer'
|
type FocusArea = 'search' | 'list' | 'footer'
|
||||||
|
|
||||||
const SEARCH_FIELD_OPTIONS = [
|
function highlight(text: string, tokens: string[]) {
|
||||||
{ label: 'Author', value: 'author' },
|
if (!text || tokens.length === 0) return text
|
||||||
{ 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']
|
const parts: React.ReactNode[] = [text]
|
||||||
|
let globalIndex = 0
|
||||||
|
|
||||||
function matchesFields(
|
tokens.forEach(token => {
|
||||||
entry: Bib2JsonEntry,
|
const next: React.ReactNode[] = []
|
||||||
query: string,
|
parts.forEach(part => {
|
||||||
fields: string[]
|
if (typeof part !== 'string') {
|
||||||
): boolean {
|
next.push(part)
|
||||||
const q = query.toLowerCase()
|
return
|
||||||
const noFilter = fields.length === 0
|
}
|
||||||
if ((noFilter || fields.includes('EntryKey')) && entry.EntryKey.toLowerCase().includes(q)) return true
|
|
||||||
const f = entry.Fields
|
const lower = part.toLowerCase()
|
||||||
if ((noFilter || fields.includes('title')) && f.title?.toLowerCase().includes(q)) return true
|
const t = token.toLowerCase()
|
||||||
if ((noFilter || fields.includes('author')) && f.author?.toLowerCase().includes(q)) return true
|
let start = 0
|
||||||
if ((noFilter || fields.includes('journal')) && f.journal?.toLowerCase().includes(q)) return true
|
let idx
|
||||||
if ((noFilter || fields.includes('year')) && f.year?.toLowerCase().includes(q)) return true
|
|
||||||
return false
|
while ((idx = lower.indexOf(t, start)) !== -1) {
|
||||||
|
if (idx > start) next.push(part.slice(start, idx))
|
||||||
|
next.push(
|
||||||
|
<span key={`${idx}-${token}-${globalIndex++}`} className="found-token">
|
||||||
|
{part.slice(idx, idx + t.length)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
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({
|
export default function ReferencePickerModal({
|
||||||
@@ -56,78 +73,97 @@ export default function ReferencePickerModal({
|
|||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||||
|
|
||||||
const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([])
|
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[]>([])
|
const pendingInitialKeysRef = useRef<string[]>([])
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (show) {
|
searchFnRef.current = searchLocalReferences
|
||||||
setQuery('')
|
}, [searchLocalReferences])
|
||||||
setSelectedKeys([])
|
|
||||||
setResults([])
|
const tokens = useMemo(
|
||||||
pendingInitialKeysRef.current = [...initialKeys]
|
() => query.toLowerCase().trim().split(/\s+/).filter(Boolean),
|
||||||
setOpenCount(c => c + 1)
|
[query]
|
||||||
}
|
)
|
||||||
}, [show, initialKeys])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
let cancelled = false
|
|
||||||
|
requestIdRef.current++
|
||||||
|
|
||||||
|
setQuery('')
|
||||||
|
setSelectedKeys([])
|
||||||
|
setResults([])
|
||||||
|
|
||||||
|
pendingInitialKeysRef.current = [...initialKeys]
|
||||||
|
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
|
||||||
const perform = async () => {
|
const perform = async () => {
|
||||||
// The module's enhanced index handles empty query as "list all"
|
const r = await searchFnRef.current('')
|
||||||
const r = await searchLocalReferences(query.trim() || '')
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
// Apply field filtering client-side
|
if (requestId !== requestIdRef.current) return
|
||||||
if (query.trim() && selectedFields.length > 0 && selectedFields.length < DEFAULT_FIELDS.length) {
|
|
||||||
setResults(r.hits.filter(h => matchesFields(h._source, query.trim(), selectedFields)))
|
setResults(r.hits)
|
||||||
} else {
|
|
||||||
setResults(r.hits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match pending initial keys (from selection) against known bib keys
|
|
||||||
if (pendingInitialKeysRef.current.length > 0) {
|
if (pendingInitialKeysRef.current.length > 0) {
|
||||||
const knownKeys = new Set(r.hits.map(h => h._source.EntryKey))
|
const knownKeys = new Set(r.hits.map(h => h._source.EntryKey))
|
||||||
const matched = pendingInitialKeysRef.current.filter(k =>
|
const matched = pendingInitialKeysRef.current.filter(k => knownKeys.has(k))
|
||||||
knownKeys.has(k)
|
if (matched.length > 0) setSelectedKeys(matched)
|
||||||
)
|
|
||||||
if (matched.length > 0) {
|
|
||||||
setSelectedKeys(matched)
|
|
||||||
}
|
|
||||||
pendingInitialKeysRef.current = []
|
pendingInitialKeysRef.current = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
perform()
|
perform()
|
||||||
return () => {
|
}, [show, initialKeys])
|
||||||
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(
|
useEffect(() => {
|
||||||
() => results.map(r => r._source.EntryKey),
|
if (!show) return
|
||||||
[results]
|
|
||||||
)
|
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<FocusArea>('search')
|
const [focusArea, setFocusArea] = useState<FocusArea>('search')
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||||
const footerRef = useRef<HTMLDivElement | null>(null)
|
const cancelButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const insertButtonRef = useRef<HTMLButtonElement | null>(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) => {
|
const toggleKey = useCallback((key: string) => {
|
||||||
setSelectedKeys(prev =>
|
setSelectedKeys(prev => {
|
||||||
prev.includes(key) ? prev.filter(x => x !== key) : [...prev, key]
|
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(() => {
|
const handleApply = useCallback(() => {
|
||||||
@@ -164,8 +200,9 @@ export default function ReferencePickerModal({
|
|||||||
)
|
)
|
||||||
} else if (event.key === ' ') {
|
} else if (event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (focusedIndex != null && filteredKeys[focusedIndex]) {
|
if (focusedIndex !== null) {
|
||||||
toggleKey(filteredKeys[focusedIndex])
|
const k = filteredKeys[focusedIndex]
|
||||||
|
if (k) toggleKey(k)
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -174,165 +211,171 @@ export default function ReferencePickerModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
|
const isShift = event.shiftKey
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const areas: FocusArea[] = ['search', 'list', 'footer']
|
event.stopPropagation()
|
||||||
const currentIdx = areas.indexOf(focusArea)
|
|
||||||
const nextIdx = event.shiftKey
|
if (!isShift) {
|
||||||
? (currentIdx - 1 + areas.length) % areas.length
|
if (focusArea === 'search') {
|
||||||
: (currentIdx + 1) % areas.length
|
setFocusArea('list')
|
||||||
const nextArea = areas[nextIdx]
|
setFocusedIndex(0)
|
||||||
setFocusArea(nextArea)
|
return
|
||||||
if (nextArea === 'list') {
|
}
|
||||||
setFocusedIndex(0)
|
if (focusArea === 'list') {
|
||||||
} else if (nextArea === 'search') {
|
setFocusArea('footer')
|
||||||
setFocusedIndex(null)
|
setFocusedIndex(null)
|
||||||
setTimeout(() => searchRef.current?.focus(), 0)
|
cancelButtonRef.current?.focus()
|
||||||
} else {
|
return
|
||||||
setFocusedIndex(null)
|
}
|
||||||
setTimeout(
|
if (focusArea === 'footer') {
|
||||||
() => footerRef.current?.querySelector('button')?.focus(),
|
if (document.activeElement === cancelButtonRef.current) {
|
||||||
0
|
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(() => {
|
useEffect(() => {
|
||||||
if (focusArea === 'list' && focusedIndex !== null) {
|
if (focusArea === 'list' && focusedIndex !== null) {
|
||||||
const el = document.getElementById(
|
const el = document.getElementById(`reference-picker-item-${focusedIndex}`)
|
||||||
`reference-picker-item-${focusedIndex}`
|
if (el) el.focus()
|
||||||
)
|
|
||||||
if (el) {
|
|
||||||
el.focus()
|
|
||||||
searchRef.current?.setAttribute('aria-activedescendant', el.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (focusArea !== 'list') {
|
|
||||||
searchRef.current?.removeAttribute('aria-activedescendant')
|
|
||||||
}
|
}
|
||||||
}, [focusArea, focusedIndex])
|
}, [focusArea, focusedIndex])
|
||||||
|
|
||||||
const toggleField = useCallback((value: string) => {
|
|
||||||
setSelectedFields(prev =>
|
|
||||||
prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OLModal show={show} onHide={onClose} size="lg">
|
<OLModal show={show} onHide={onClose} size="lg">
|
||||||
<OLModalHeader>
|
<OLModalHeader>
|
||||||
<OLModalTitle data-testid="reference-picker-title">
|
<OLModalTitle>{t('references_picker_title')}</OLModalTitle>
|
||||||
{t('references_picker_title')}
|
|
||||||
</OLModalTitle>
|
|
||||||
</OLModalHeader>
|
</OLModalHeader>
|
||||||
<OLModalBody>
|
<div onKeyDown={onKeyDown}>
|
||||||
<div onKeyDown={onKeyDown} className="reference-picker">
|
<OLModalBody className="references-search-modal">
|
||||||
<input
|
<div className="container-fluid">
|
||||||
aria-label={t('search_references')}
|
<OLRow>
|
||||||
type="search"
|
<OLFormGroup>
|
||||||
value={query}
|
<OLFormControl
|
||||||
onChange={e => setQuery(e.target.value)}
|
name="search"
|
||||||
autoFocus
|
aria-label={t('search_references')}
|
||||||
ref={searchRef}
|
type="search"
|
||||||
className="form-control"
|
value={query}
|
||||||
data-testid="reference-picker-search"
|
onChange={e => setQuery(e.target.value)}
|
||||||
/>
|
placeholder={t('search_references')}
|
||||||
|
prepend={<MaterialIcon type="search" />}
|
||||||
<div className="search-selectors">
|
ref={searchRef}
|
||||||
{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>
|
</OLFormGroup>
|
||||||
</label>
|
</OLRow>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<OLRow>
|
||||||
className="selected-chips"
|
<div className="selected-key-tag">
|
||||||
data-testid="reference-picker-selected-chips"
|
{selectedKeys.map((key, idx) => (
|
||||||
>
|
<OLTag
|
||||||
{selectedKeys.map(key => (
|
key={`${key}-${idx}`}
|
||||||
<Tag key={key} closeBtnProps={{ onClick: () => toggleKey(key) }}>
|
closeBtnProps={{ onClick: () => removeOne(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">
|
{key}
|
||||||
<input
|
</OLTag>
|
||||||
type="checkbox"
|
))}
|
||||||
checked={selectedKeys.includes(key)}
|
</div>
|
||||||
onChange={() => toggleKey(key)}
|
</OLRow>
|
||||||
/>
|
|
||||||
<span className="hit-key">{key}</span>
|
<OLRow className="search-results">
|
||||||
</div>
|
<OLCol md={12} className="search-results-scroll-container">
|
||||||
<div className="hit-main">
|
<ul className="list-unstyled">
|
||||||
<span className="hit-title">{title}</span>
|
{results.map((hit, index) => {
|
||||||
<span className="hit-meta">{meta}</span>
|
const key = hit._source.EntryKey
|
||||||
</div>
|
const { title = '', author = '', year = '', journal = '' } =
|
||||||
</label>
|
hit._source.Fields ?? {}
|
||||||
)
|
|
||||||
})
|
return (
|
||||||
)}
|
<li
|
||||||
|
key={key}
|
||||||
|
id={`reference-picker-item-${index}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusArea('list')
|
||||||
|
setFocusedIndex(index)
|
||||||
|
toggleKey(key)
|
||||||
|
}}
|
||||||
|
className={`search-result-hit ${
|
||||||
|
focusedIndex === index ? 'focused' : ''
|
||||||
|
} ${selectedKeys.includes(key) ? 'selected-search-result-hit' : ''}`}
|
||||||
|
>
|
||||||
|
<OLRow>
|
||||||
|
<OLCol md={12}>
|
||||||
|
<span className="hit-title">
|
||||||
|
{highlight(title, tokens)}
|
||||||
|
</span>
|
||||||
|
<span className="float-end">
|
||||||
|
{highlight(key, tokens)}
|
||||||
|
</span>
|
||||||
|
</OLCol>
|
||||||
|
</OLRow>
|
||||||
|
|
||||||
|
<OLRow>
|
||||||
|
<OLCol md={12}>
|
||||||
|
{[
|
||||||
|
highlight(author, tokens),
|
||||||
|
highlight(journal, tokens),
|
||||||
|
highlight(year, tokens),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, val, i) =>
|
||||||
|
i === 0 ? [val] : acc.concat('\u00A0—\u00A0', val),
|
||||||
|
[])}
|
||||||
|
</OLCol>
|
||||||
|
</OLRow>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</OLCol>
|
||||||
|
</OLRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</OLModalBody>
|
||||||
</OLModalBody>
|
|
||||||
<OLModalFooter>
|
<OLModalFooter>
|
||||||
<div ref={footerRef}>
|
<OLButton variant="secondary" onClick={onClose} ref={cancelButtonRef}>
|
||||||
<OLButton variant="secondary" onClick={onClose}>
|
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</OLButton>
|
</OLButton>
|
||||||
<OLButton
|
<OLButton variant="primary" onClick={handleApply} ref={insertButtonRef}>
|
||||||
variant="primary"
|
{initialKeys.length ? t('search_replace') : t('insert')}
|
||||||
onClick={handleApply}
|
|
||||||
data-testid="reference-picker-insert"
|
|
||||||
>
|
|
||||||
{t('insert')}
|
|
||||||
</OLButton>
|
</OLButton>
|
||||||
</div>
|
</OLModalFooter>
|
||||||
</OLModalFooter>
|
</div>
|
||||||
</OLModal>
|
</OLModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function parseTokens(
|
|||||||
text: string
|
text: string
|
||||||
): { value: string; start: number; end: number }[] {
|
): { value: string; start: number; end: number }[] {
|
||||||
const tokens: { value: string; start: number; end: number }[] = []
|
const tokens: { value: string; start: number; end: number }[] = []
|
||||||
const re = /[^\s,]+/g
|
const re = /[^\s,%]+/g
|
||||||
let m
|
let m
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
tokens.push({ value: m[0], start: m.index, end: m.index + m[0].length })
|
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 selTo = Math.min(braceTo, mainSel.to)
|
||||||
const hasSelection = selFrom < selTo
|
const hasSelection = selFrom < selTo
|
||||||
|
|
||||||
|
const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
const selectedText = view.state.doc.sliceString(selFrom, selTo)
|
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
|
// Parse the full cite content into tokens for partial-key expansion
|
||||||
const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
|
|
||||||
const tokens = parseTokens(fullContent)
|
const tokens = parseTokens(fullContent)
|
||||||
|
|
||||||
// Relative selection offsets within the cite content
|
// Relative selection offsets within the cite content
|
||||||
@@ -92,20 +84,31 @@ function openPickerIfInCite(view: EditorView): boolean {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
window.dispatchEvent(
|
||||||
new CustomEvent('reference:openPicker', {
|
new CustomEvent('reference:openPicker', {
|
||||||
detail: {
|
detail: {
|
||||||
braceFrom,
|
braceFrom,
|
||||||
braceTo,
|
braceTo,
|
||||||
insertFrom: pos,
|
insertFrom: insertPos,
|
||||||
insertTo: pos,
|
insertTo: insertPos,
|
||||||
selectedTokens: [],
|
selectedTokens: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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<string, Set<string>> = new Map()
|
||||||
|
entryIndex: Map<string, Bib2JsonEntry> = new Map()
|
||||||
|
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string): Promise<AdvancedReferenceSearchResult> {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +1,38 @@
|
|||||||
.reference-picker {
|
.references-search-modal {
|
||||||
display: flex;
|
.selected-key-tag {
|
||||||
flex-direction: column;
|
position: relative;
|
||||||
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-02);
|
gap: var(--spacing-02);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-height: 72px;
|
min-height: 30px;
|
||||||
|
max-height: 58px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
|
||||||
|
|
||||||
.search-selectors {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-02);
|
|
||||||
align-items: center;
|
|
||||||
font-size: var(--font-size-02);
|
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 {
|
.search-results {
|
||||||
display: inline-flex;
|
.search-results-scroll-container {
|
||||||
align-items: center;
|
max-height: calc(100vh - 330px);
|
||||||
gap: 6px;
|
overflow-y: auto;
|
||||||
font-size: 12px;
|
}
|
||||||
}
|
.search-result-hit {
|
||||||
|
&:focus {
|
||||||
[role='listbox'] {
|
background: var(--ds-color-yellow-50);
|
||||||
max-height: calc(100vh - 320px);
|
outline: none;
|
||||||
overflow-y: auto;
|
}
|
||||||
border-top: 1px solid var(--border-divider);
|
}
|
||||||
border-bottom: 1px solid var(--border-divider);
|
.found-token {
|
||||||
}
|
color: var(--ds-color-red-600);
|
||||||
|
font-weight: bold;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"last 1 year",
|
"last 1 year",
|
||||||
"safari > 14"
|
"safari > 14"
|
||||||
],
|
],
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/mcp": "^1.0.0",
|
"@ai-sdk/mcp": "^1.0.0",
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
@@ -139,7 +140,6 @@
|
|||||||
"express": "4.22.1",
|
"express": "4.22.1",
|
||||||
"file-type": "^21.0.0",
|
"file-type": "^21.0.0",
|
||||||
"focus-trap-react": "^11.0.4",
|
"focus-trap-react": "^11.0.4",
|
||||||
"fuse.js": "^7.0.0",
|
|
||||||
"globby": "^5.0.0",
|
"globby": "^5.0.0",
|
||||||
"helmet": "^6.0.1",
|
"helmet": "^6.0.1",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
@@ -423,4 +423,4 @@
|
|||||||
"yup": "^0.32.11",
|
"yup": "^0.32.11",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user