mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Reference Picker: Drag-and-drop badges
- Make badges draggable with the mouse - Replace Insert and Replace modes with a single Apply action - Add a hint for the keyboard shortcut - Reorganize directory structure
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useCitationHint } from '../hooks/use-citation-hint'
|
||||
import ReferencePickerModal from './reference-picker-modal'
|
||||
import { citationRange } from '../extensions/reference-picker-keybinding'
|
||||
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
|
||||
|
||||
type Picker = {
|
||||
from: number
|
||||
to: number
|
||||
initialKeys: string[]
|
||||
newCitation: boolean
|
||||
}
|
||||
|
||||
export default function ReferencePickerController() {
|
||||
const { view } = useEditorViewContext()
|
||||
const [picker, setPicker] = useState<Picker | null>(null)
|
||||
|
||||
useCitationHint(view, picker === null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return
|
||||
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail
|
||||
const range = detail?.braceFrom != null ? detail : citationRange(view)
|
||||
const from = range?.braceFrom ?? view.state.selection.main.head
|
||||
const to = range?.braceTo ?? from
|
||||
const content = view.state.doc.sliceString(from, to)
|
||||
const initialKeys = content.replace(/%[^\n]*/g, '').match(/[^\s,]+/g) ?? []
|
||||
setPicker({ from, to, initialKeys, newCitation: !range })
|
||||
}
|
||||
|
||||
window.addEventListener('reference:openPicker', open)
|
||||
return () => window.removeEventListener('reference:openPicker', open)
|
||||
}, [view])
|
||||
|
||||
if (!picker || !view) return null
|
||||
|
||||
function save(keys: string[]) {
|
||||
if (!picker || !view) return
|
||||
const { from, to, initialKeys, newCitation } = picker
|
||||
const changed = keys.length !== initialKeys.length ||
|
||||
keys.some((key, index) => key !== initialKeys[index])
|
||||
if (newCitation || changed) {
|
||||
const content = keys.join(', ')
|
||||
const insert = newCitation ? `\\cite{${content}}` : content
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: newCitation ? from + insert.length - 1 : from + insert.length },
|
||||
})
|
||||
}
|
||||
view.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<ReferencePickerModal
|
||||
show
|
||||
onClose={() => setPicker(null)}
|
||||
onApply={save}
|
||||
initialKeys={picker.initialKeys}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
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 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'
|
||||
|
||||
type BadgeDrag = {
|
||||
key: string
|
||||
from: number | null
|
||||
source: 'row' | 'badge'
|
||||
}
|
||||
|
||||
// Walk the flex items in visual order. The invisible badge occupies space
|
||||
// but doesn't count towards the insertion index.
|
||||
function insertionSlot(event: React.DragEvent<HTMLDivElement>) {
|
||||
const badges = Array.from(event.currentTarget.children) as HTMLElement[]
|
||||
badges.sort((a, b) => Number(a.style.order) - Number(b.style.order))
|
||||
|
||||
let slot = 0
|
||||
let count = 0
|
||||
let nearestDistance = Infinity
|
||||
let rowCentre = 0
|
||||
|
||||
for (const badge of badges) {
|
||||
const before = count
|
||||
if (!badge.hasAttribute('data-drag-source')) count++
|
||||
|
||||
const rect = badge.getBoundingClientRect()
|
||||
const centre = rect.top + rect.height / 2
|
||||
const distance = Math.abs(event.clientY - centre)
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance
|
||||
rowCentre = centre
|
||||
slot = before
|
||||
}
|
||||
if (centre === rowCentre && event.clientX >= rect.left + rect.width / 2) {
|
||||
slot = count
|
||||
}
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
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(
|
||||
<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({
|
||||
show,
|
||||
onClose,
|
||||
onApply,
|
||||
initialKeys,
|
||||
}: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onApply: (selectedKeys: string[]) => void
|
||||
initialKeys: string[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { searchLocalReferences } = useReferencesContext()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>(initialKeys)
|
||||
type Hit = { _source: Bib2JsonEntry }
|
||||
const [allResults, setAllResults] = useState<Hit[]>([])
|
||||
const [filteredResults, setFilteredResults] = useState<Hit[]>([])
|
||||
const search = query.trim()
|
||||
const results = search ? filteredResults : allResults
|
||||
const knownKeys = useMemo(
|
||||
() => new Set(allResults.map(hit => hit._source.EntryKey)),
|
||||
[allResults]
|
||||
)
|
||||
|
||||
const [drag, setDrag] = useState<BadgeDrag | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<number | null>(null)
|
||||
|
||||
function startDrag(event: React.DragEvent, drag: BadgeDrag) {
|
||||
const { key, from } = drag
|
||||
event.dataTransfer.effectAllowed = from === null ? 'copy' : 'move'
|
||||
event.dataTransfer.setData('application/x-reference-picker-key', key)
|
||||
setDrag(drag)
|
||||
setDropTarget(null)
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
setDrag(null)
|
||||
setDropTarget(null)
|
||||
}
|
||||
|
||||
function dropBadge(event: React.DragEvent<HTMLDivElement>) {
|
||||
if (!drag) return
|
||||
event.preventDefault()
|
||||
const to = dropTarget ?? insertionSlot(event)
|
||||
endDrag()
|
||||
if (drag.from === to) return
|
||||
|
||||
setSelectedKeys(keys => {
|
||||
const next = [...keys]
|
||||
if (drag.from !== null) next.splice(drag.from, 1)
|
||||
next.splice(to, 0, drag.key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const tokens = useMemo(
|
||||
() => query.toLowerCase().trim().split(/\s+/).filter(Boolean),
|
||||
[query]
|
||||
)
|
||||
|
||||
const searchFnRef = useRef(searchLocalReferences)
|
||||
useEffect(() => {
|
||||
searchFnRef.current = searchLocalReferences
|
||||
}, [searchLocalReferences])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
Promise.resolve(searchFnRef.current('')).then(response => {
|
||||
if (active) setAllResults(response.hits)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) return
|
||||
|
||||
let active = true
|
||||
Promise.resolve(searchFnRef.current(search)).then(response => {
|
||||
if (active) setFilteredResults(response.hits)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [search])
|
||||
|
||||
const filteredKeys = results.map(r => r._source.EntryKey)
|
||||
|
||||
const [focusArea, setFocusArea] = useState<FocusArea>('search')
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||
const cancelButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const saveButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
const scrollToReference = useCallback((key: string) => {
|
||||
const index = results.findIndex(
|
||||
r => r._source.EntryKey === key
|
||||
)
|
||||
|
||||
if (index === -1) return
|
||||
|
||||
const el = document.getElementById(
|
||||
`reference-picker-item-${index}`
|
||||
)
|
||||
|
||||
el?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
|
||||
setFocusArea('list')
|
||||
setFocusedIndex(index)
|
||||
}, [results])
|
||||
|
||||
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 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<HTMLDivElement>) => {
|
||||
if (focusArea === 'search') {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setFocusArea('list')
|
||||
setFocusedIndex(0)
|
||||
}
|
||||
} else if (focusArea === 'list') {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setFocusedIndex(prev =>
|
||||
prev == null ? 0 : Math.min(filteredKeys.length - 1, prev + 1)
|
||||
)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setFocusedIndex(prev =>
|
||||
prev == null ? 0 : Math.max(0, prev - 1)
|
||||
)
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (focusedIndex !== null) {
|
||||
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) {
|
||||
saveButtonRef.current?.focus()
|
||||
return
|
||||
} else {
|
||||
setFocusArea('search')
|
||||
setFocusedIndex(null)
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isShift) {
|
||||
if (focusArea === 'search') {
|
||||
setFocusArea('footer')
|
||||
setFocusedIndex(null)
|
||||
saveButtonRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (focusArea === 'list') {
|
||||
setFocusArea('search')
|
||||
setFocusedIndex(null)
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (focusArea === 'footer') {
|
||||
if (document.activeElement === saveButtonRef.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])
|
||||
|
||||
const previewKeys =
|
||||
drag && drag.from === null && dropTarget !== null
|
||||
? [...selectedKeys, drag.key]
|
||||
: selectedKeys
|
||||
const sourceIndex = drag ? drag.from ?? selectedKeys.length : null
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={onClose} size="lg" themed>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('references_picker_title')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<div onKeyDown={onKeyDown}>
|
||||
<OLModalBody className="references-search-modal">
|
||||
<div className="container-fluid">
|
||||
<OLRow>
|
||||
<OLFormGroup>
|
||||
<OLFormControl
|
||||
name="search"
|
||||
aria-label={t('search_references')}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('search_references')}
|
||||
prepend={<MaterialIcon type="search" />}
|
||||
ref={searchRef}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLRow>
|
||||
|
||||
<OLRow>
|
||||
<div
|
||||
className="selected-key-tags"
|
||||
onDragOver={event => {
|
||||
if (!drag) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = drag.from === null ? 'copy' : 'move'
|
||||
setDropTarget(insertionSlot(event))
|
||||
}}
|
||||
onDragLeave={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setDropTarget(null)
|
||||
}
|
||||
}}
|
||||
onDrop={dropBadge}
|
||||
>
|
||||
{previewKeys.map((key, idx) => (
|
||||
<span
|
||||
key={`${key}-${idx}`}
|
||||
draggable={selectedKeys.length > 1}
|
||||
onDragStart={event => startDrag(event, { key, from: idx, source: 'badge' })}
|
||||
onDragEnd={endDrag}
|
||||
data-drag-source={idx === sourceIndex ? drag?.source : undefined}
|
||||
data-drop-slot={
|
||||
(idx === sourceIndex && dropTarget !== null) || undefined
|
||||
}
|
||||
style={{
|
||||
order:
|
||||
idx === sourceIndex && dropTarget !== null
|
||||
? 2 * dropTarget + (dropTarget > idx ? 1 : -1)
|
||||
: 2 * idx,
|
||||
}}
|
||||
className={
|
||||
knownKeys.has(key)
|
||||
? 'existing-key-tag'
|
||||
: 'nonexistent-key-tag'
|
||||
}
|
||||
>
|
||||
<OLTag
|
||||
contentProps={{ onClick: () => scrollToReference(key) }}
|
||||
closeBtnProps={{ onClick: () => removeOne(key) }}
|
||||
>
|
||||
{key}
|
||||
</OLTag>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</OLRow>
|
||||
|
||||
<OLRow className="search-results">
|
||||
<OLCol md={12} className="search-results-scroll-container">
|
||||
<ul className="list-unstyled">
|
||||
{results.map((hit, index) => {
|
||||
const key = hit._source.EntryKey
|
||||
const { title = '', author = '', year = '', journal = '' } =
|
||||
hit._source.Fields ?? {}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
id={`reference-picker-item-${index}`}
|
||||
tabIndex={-1}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
const index = selectedKeys.indexOf(key)
|
||||
startDrag(event, {
|
||||
key,
|
||||
from: index === -1 ? null : index,
|
||||
source: 'row',
|
||||
})
|
||||
const label = event.currentTarget.querySelector<HTMLElement>(
|
||||
'.reference-result-key'
|
||||
)
|
||||
if (label) {
|
||||
label.classList.add('drag-image')
|
||||
event.dataTransfer.setDragImage(label, 8, label.offsetHeight / 2)
|
||||
}
|
||||
}}
|
||||
onDragEnd={event => {
|
||||
event.currentTarget.querySelector('.reference-result-key')
|
||||
?.classList.remove('drag-image')
|
||||
endDrag()
|
||||
}}
|
||||
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 reference-result-key">
|
||||
{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>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={onClose} ref={cancelButtonRef}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="primary" onClick={handleApply} ref={saveButtonRef}>
|
||||
{t('apply')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</div>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { EditorView, keymap, showTooltip, type Tooltip } from '@codemirror/view'
|
||||
import { Prec, StateEffect, StateField, type Extension } from '@codemirror/state'
|
||||
import { startCompletion } from '@codemirror/autocomplete'
|
||||
import { getBibkeyArgumentNode } from '@/features/source-editor/utils/tree-operations/ancestors'
|
||||
|
||||
export const setCitationHint = StateEffect.define<Tooltip | null>()
|
||||
|
||||
export const citationHint = StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(value, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(setCitationHint)) return effect.value
|
||||
}
|
||||
return transaction.docChanged ||
|
||||
!transaction.state.selection.eq(transaction.startState.selection)
|
||||
? null
|
||||
: value
|
||||
},
|
||||
provide: field => showTooltip.from(field),
|
||||
})
|
||||
|
||||
export function citationRange(view: EditorView) {
|
||||
const { doc, selection } = view.state
|
||||
const pos = selection.main.head
|
||||
const node = getBibkeyArgumentNode(view.state, pos)
|
||||
if (!node) return null
|
||||
|
||||
const from = node.from + 1
|
||||
const to = node.to - 1
|
||||
if (
|
||||
pos < from || pos > to ||
|
||||
doc.sliceString(node.from, from) !== '{' ||
|
||||
doc.sliceString(to, node.to) !== '}'
|
||||
) return null
|
||||
|
||||
return { braceFrom: from, braceTo: to }
|
||||
}
|
||||
|
||||
function openPickerIfInCite(view: EditorView): boolean {
|
||||
try {
|
||||
const range = citationRange(view)
|
||||
if (range) {
|
||||
window.dispatchEvent(new CustomEvent('reference:openPicker', { detail: range }))
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Fall back to ordinary completion during parser recovery.
|
||||
}
|
||||
return startCompletion(view)
|
||||
}
|
||||
|
||||
export const extension = (): Extension => [
|
||||
citationHint,
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{ key: 'Ctrl-Space', run: openPickerIfInCite },
|
||||
{ key: 'Alt-Space', run: startCompletion },
|
||||
])
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Compartment, StateEffect } from '@codemirror/state'
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTutorial from '@/shared/hooks/promotions/use-tutorial'
|
||||
import { useTutorialContext } from '@/shared/context/tutorial-context'
|
||||
import {
|
||||
citationHint,
|
||||
citationRange,
|
||||
setCitationHint,
|
||||
} from '../extensions/reference-picker-keybinding'
|
||||
|
||||
const tutorialKey = 'reference-picker-shortcut'
|
||||
const eventData = { tutorial: tutorialKey }
|
||||
|
||||
export function useCitationHint(view: EditorView | null | undefined, enabled: boolean) {
|
||||
const { t } = useTranslation()
|
||||
const inputHint = useRef(new Compartment())
|
||||
const { dismissTutorial } = useTutorial(tutorialKey, eventData)
|
||||
const { inactiveTutorials, currentPopup } = useTutorialContext()
|
||||
const active = enabled && !currentPopup && !inactiveTutorials.includes(tutorialKey)
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || !active || view.state.field(citationHint, false) === undefined) return
|
||||
const doc = view.dom.ownerDocument
|
||||
const events = new AbortController()
|
||||
const { signal } = events
|
||||
|
||||
const hide = () => {
|
||||
if (view.state.field(citationHint, false)) {
|
||||
view.dispatch({ effects: setCitationHint.of(null) })
|
||||
}
|
||||
}
|
||||
|
||||
const open = (pos: number | null) => {
|
||||
if (view.state.readOnly || !view.state.selection.main.empty) return
|
||||
const range = citationRange(view)
|
||||
if (pos === null || !range || pos < range.braceFrom || pos > range.braceTo) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: setCitationHint.of({
|
||||
pos,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = doc.createElement('div')
|
||||
dom.className = 'reference-picker-shortcut-hint'
|
||||
const message = doc.createElement('div')
|
||||
message.className = 'reference-picker-shortcut-message'
|
||||
const icon = doc.createElement('span')
|
||||
icon.className = 'material-symbols'
|
||||
icon.setAttribute('aria-hidden', 'true')
|
||||
icon.setAttribute('translate', 'no')
|
||||
icon.textContent = 'info'
|
||||
message.append(icon, t('reference_picker_shortcut_hint', { shortcut: 'Ctrl+Space' }))
|
||||
dom.append(message)
|
||||
|
||||
const button = doc.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'd-inline-grid btn btn-sm btn-danger-ghost'
|
||||
button.textContent = t('reference_picker_hide_hint')
|
||||
button.onclick = () => {
|
||||
hide()
|
||||
view.focus()
|
||||
void dismissTutorial()
|
||||
}
|
||||
dom.append(button)
|
||||
// Read the editor theme directly; the overall UI theme is independent.
|
||||
const syncButtonTheme = () => {
|
||||
const dark = view.state.facet(EditorView.darkTheme)
|
||||
const colors = {
|
||||
color: dark ? '--bg-danger-03' : '--content-danger',
|
||||
background: dark ? '--bg-dark-secondary' : '--bg-light-primary',
|
||||
'hover-background': dark ? '--bg-dark-tertiary' : '--bg-danger-03',
|
||||
border: dark ? '--bg-danger-01' : '--bg-light-primary',
|
||||
'hover-border': dark ? '--bg-danger-01' : '--bg-light-primary',
|
||||
}
|
||||
for (const [name, color] of Object.entries(colors)) {
|
||||
button.style.setProperty(`--btn-danger-ghost-${name}`, `var(${color})`)
|
||||
}
|
||||
}
|
||||
syncButtonTheme()
|
||||
return {
|
||||
dom,
|
||||
update(update) {
|
||||
if (update.startState.facet(EditorView.darkTheme) !==
|
||||
update.state.facet(EditorView.darkTheme)) syncButtonTheme()
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const listener = EditorView.updateListener.of(update => {
|
||||
if (!update.docChanged) return
|
||||
const { state } = update
|
||||
const pos = state.selection.main.head
|
||||
if (!state.selection.main.empty || pos === 0 || pos >= state.doc.length ||
|
||||
state.doc.sliceString(pos - 1, pos + 1) !== '{}') return
|
||||
|
||||
// Only newly inserted braces qualify, not edits elsewhere in the document.
|
||||
let insertedOpeningBrace = false
|
||||
update.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => {
|
||||
if (fromB < pos && toB >= pos) {
|
||||
insertedOpeningBrace = true
|
||||
}
|
||||
})
|
||||
if (!insertedOpeningBrace) return
|
||||
|
||||
// Dispatch after CodeMirror finishes processing the input transaction.
|
||||
queueMicrotask(() => {
|
||||
if (signal.aborted || !view.hasFocus ||
|
||||
!view.state.doc.eq(state.doc) ||
|
||||
!view.state.selection.eq(state.selection)) return
|
||||
// Completion/snippet effects can create a new state without changing
|
||||
// the document. Parse the newly inserted argument before inspecting it.
|
||||
if (ensureSyntaxTree(view.state, pos + 1, 50)) open(pos)
|
||||
})
|
||||
})
|
||||
const compartment = inputHint.current
|
||||
view.dispatch({
|
||||
effects: compartment.get(view.state) === undefined
|
||||
? StateEffect.appendConfig.of(compartment.of(listener))
|
||||
: compartment.reconfigure(listener),
|
||||
})
|
||||
|
||||
const outside = (event: Event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (view.contentDOM.contains(event.target) ||
|
||||
event.target.closest('.reference-picker-shortcut-hint')) return
|
||||
hide()
|
||||
}
|
||||
view.contentDOM.addEventListener('click', event => {
|
||||
if (event.button === 0) {
|
||||
open(view.posAtCoords({ x: event.clientX, y: event.clientY }))
|
||||
}
|
||||
}, { signal })
|
||||
doc.addEventListener('pointerdown', outside, { signal })
|
||||
doc.addEventListener('focusin', outside, { signal })
|
||||
doc.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') hide()
|
||||
}, { signal })
|
||||
|
||||
return () => {
|
||||
events.abort()
|
||||
view.dispatch({
|
||||
effects: [setCitationHint.of(null), compartment.reconfigure([])],
|
||||
})
|
||||
}
|
||||
}, [active, view, t, dismissTutorial])
|
||||
}
|
||||
+97
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user