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:
yu-i-i
2026-11-06 14:45:52 +01:00
parent 5c9fef4d03
commit ae07e781ab
15 changed files with 492 additions and 291 deletions
@@ -4,6 +4,7 @@ import { expressify } from '@overleaf/promise-utils'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs' import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
const VALID_KEYS = [ const VALID_KEYS = [
'reference-picker-shortcut',
'react-history-buttons-tutorial', 'react-history-buttons-tutorial',
'writefull-integration', 'writefull-integration',
'writefull-oauth-promotion', 'writefull-oauth-promotion',
+3 -3
View File
@@ -1065,7 +1065,7 @@ module.exports = {
mainEditorLayoutModals: [ mainEditorLayoutModals: [
Path.resolve( Path.resolve(
__dirname, __dirname,
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx' '../modules/reference-picker/frontend/js/components/reference-picker-controller.tsx'
), ),
Path.resolve( Path.resolve(
__dirname, __dirname,
@@ -1140,7 +1140,7 @@ module.exports = {
autoCompleteExtensions: [ autoCompleteExtensions: [
Path.resolve( Path.resolve(
__dirname, __dirname,
'../modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts' '../modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts'
), ),
], ],
sectionTitleGenerators: [], sectionTitleGenerators: [],
@@ -1194,7 +1194,7 @@ module.exports = {
referenceIndices: [ referenceIndices: [
Path.resolve( Path.resolve(
__dirname, __dirname,
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts' '../modules/reference-picker/frontend/js/reference-index/advanced-reference-index.ts'
), ),
], ],
railEntries: [], railEntries: [],
@@ -1762,6 +1762,8 @@
"reference_manager_searched_groups": "", "reference_manager_searched_groups": "",
"reference_manager_unlinked": "", "reference_manager_unlinked": "",
"reference_managers": "", "reference_managers": "",
"reference_picker_hide_hint": "",
"reference_picker_shortcut_hint": "",
"reference_search": "", "reference_search": "",
"reference_search_setting": "", "reference_search_setting": "",
"reference_search_results": "", "reference_search_results": "",
@@ -16,4 +16,4 @@
@import '../../../modules/admin-tools/frontend/styles/manage-users-page'; @import '../../../modules/admin-tools/frontend/styles/manage-users-page';
@import '../../../modules/admin-tools/frontend/styles/pagination-dark'; @import '../../../modules/admin-tools/frontend/styles/pagination-dark';
@import '../../../modules/github-sync/frontend/styles/github-sync'; @import '../../../modules/github-sync/frontend/styles/github-sync';
@import '../../../modules/reference-picker/frontend/styles/reference-picker'; @import '../../../modules/reference-picker/frontend/stylesheets/reference-picker';
+2
View File
@@ -1402,6 +1402,8 @@
"reference": "Referenz", "reference": "Referenz",
"reference_error_relink_hint": "Wenn dieser Fehler weiterhin auftritt, versuche dein Konto hier neu zu verlinken:", "reference_error_relink_hint": "Wenn dieser Fehler weiterhin auftritt, versuche dein Konto hier neu zu verlinken:",
"reference_managers": "Referenzmanager", "reference_managers": "Referenzmanager",
"reference_picker_hide_hint": "Nicht mehr anzeigen",
"reference_picker_shortcut_hint": "Drücke __shortcut__, um Literaturstellen zu suchen.",
"reference_search": "Erweiterte Referenzsuche", "reference_search": "Erweiterte Referenzsuche",
"reference_sync": "Referenzmanager synchronisieren", "reference_sync": "Referenzmanager synchronisieren",
"references": "Referenzen", "references": "Referenzen",
+2
View File
@@ -2313,6 +2313,8 @@
"reference_manager_searched_groups": "__provider__ search groups", "reference_manager_searched_groups": "__provider__ search groups",
"reference_manager_unlinked": "Reference manager unlinked", "reference_manager_unlinked": "Reference manager unlinked",
"reference_managers": "Reference managers", "reference_managers": "Reference managers",
"reference_picker_hide_hint": "Don’t show again",
"reference_picker_shortcut_hint": "Press __shortcut__ to find references.",
"reference_search": "Advanced reference search", "reference_search": "Advanced reference search",
"reference_search_info_new": "Find your references easily—search by author, title, year, or journal.", "reference_search_info_new": "Find your references easily—search by author, title, year, or journal.",
"reference_search_results": "Reference search results", "reference_search_results": "Reference search results",
+3
View File
@@ -79,6 +79,7 @@
"and": "и", "and": "и",
"annual": "Цена за год", "annual": "Цена за год",
"anonymous": "Аноним", "anonymous": "Аноним",
"apply": "Применить",
"apply_educational_discount_description_with_group_discount": "Получите суммарную скидку 40% для групп, использующих __appName__ в обучении", "apply_educational_discount_description_with_group_discount": "Получите суммарную скидку 40% для групп, использующих __appName__ в обучении",
"are_you_sure_you_want_to_cancel": "Вы уверены, что хотите отменить подписку?", "are_you_sure_you_want_to_cancel": "Вы уверены, что хотите отменить подписку?",
"are_you_sure_you_want_to_cancel_add_on": "Вы уверены, что хотите отменить расширение <strong>__addOnName__</strong>?", "are_you_sure_you_want_to_cancel_add_on": "Вы уверены, что хотите отменить расширение <strong>__addOnName__</strong>?",
@@ -618,6 +619,8 @@
"recurly_email_updated": "Адрес электронной почты для счетов успешно обновлён", "recurly_email_updated": "Адрес электронной почты для счетов успешно обновлён",
"reduce_costs_group_licenses": "С нашими групповыми лицензиями со скидкой Вы сократите бумажную работу и расходы.", "reduce_costs_group_licenses": "С нашими групповыми лицензиями со скидкой Вы сократите бумажную работу и расходы.",
"reference": "Ссылка", "reference": "Ссылка",
"reference_picker_hide_hint": "Больше не показывать",
"reference_picker_shortcut_hint": "Нажмите __shortcut__, чтобы найти ссылки на источники.",
"reference_search": "Расширенный поиск по источникам", "reference_search": "Расширенный поиск по источникам",
"reference_sync": "Синхронизация с менеджером библиографии", "reference_sync": "Синхронизация с менеджером библиографии",
"references": "Ссылки", "references": "Ссылки",
@@ -1,92 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import ReferencePickerModal from './reference-picker-modal'
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
export default function ReferencePickerController() {
const { view } = useEditorViewContext()
const [open, setOpen] = useState(false)
const [from, setFrom] = useState<number | null>(null)
const [to, setTo] = useState<number | null>(null)
const [braceFrom, setBraceFrom] = useState<number | null>(null)
const [braceTo, setBraceTo] = useState<number | null>(null)
const [initialKeys, setInitialKeys] = useState<string[]>([])
const onClose = useCallback(() => setOpen(false), [])
const onApply = useCallback(
(selectedKeys: string[]) => {
if (!view || from == null || to == null) return
const doc = view.state.doc
const bFrom = braceFrom ?? from
const bTo = braceTo ?? to
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]
)
useEffect(() => {
const handler = (evt: CustomEvent) => {
const detail = evt.detail || {}
if (detail.insertFrom == null) {
if (!view) return
const pos = view.state.selection.main.head
const insertText = '\\cite{}'
view.dispatch({
changes: { from: pos, to: pos, insert: insertText }
})
const newFrom = pos + insertText.indexOf('{') + 1
setFrom(newFrom)
setTo(newFrom)
setBraceFrom(newFrom)
setBraceTo(newFrom)
setInitialKeys([])
} else {
setFrom(detail.insertFrom)
setTo(detail.insertTo)
setBraceFrom(detail.braceFrom ?? detail.insertFrom)
setBraceTo(detail.braceTo ?? detail.insertTo)
setInitialKeys(detail.selectedTokens || [])
}
setOpen(true)
}
window.addEventListener('reference:openPicker', handler as EventListener)
return () => window.removeEventListener('reference:openPicker', handler as EventListener)
}, [view])
return (
<ReferencePickerModal
show={open}
onClose={onClose}
onApply={onApply}
initialKeys={initialKeys}
/>
)
}
@@ -1,126 +0,0 @@
import { keymap } from '@codemirror/view'
import { Prec, Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { startCompletion } from '@codemirror/autocomplete'
import { getBibkeyArgumentNode } from '@/features/source-editor/utils/tree-operations/ancestors'
/**
* Parse cite-argument text into non-separator tokens with their
* start/end positions relative to the beginning of the text.
* Separators are commas and whitespace (space, tab, newline).
*/
function parseTokens(
text: string
): { value: string; start: number; end: number }[] {
const tokens: { value: string; start: number; end: number }[] = []
const re = /[^\s,%]+/g
let m
while ((m = re.exec(text)) !== null) {
tokens.push({ value: m[0], start: m.index, end: m.index + m[0].length })
}
return tokens
}
function openPickerIfInCite(view: EditorView): boolean {
try {
const mainSel = view.state.selection.main
const pos = mainSel.head
const node = getBibkeyArgumentNode(view.state, pos)
if (node) {
// Verify the node has proper matching braces — the tree parser
// may produce incorrect boundaries during error recovery (e.g.
// unclosed \cite{ or nested braces like {\bf ...}).
const openChar = view.state.doc.sliceString(node.from, node.from + 1)
const closeChar = view.state.doc.sliceString(node.to - 1, node.to)
if (openChar !== '{' || closeChar !== '}') {
return startCompletion(view)
}
const braceFrom = node.from + 1
const braceTo = node.to - 1
// Cursor must be inside the braces (between braceFrom and braceTo inclusive)
if (pos < braceFrom || pos > braceTo) {
return startCompletion(view)
}
// Clamp selection to brace boundaries
const selFrom = Math.max(braceFrom, mainSel.from)
const selTo = Math.min(braceTo, mainSel.to)
const hasSelection = selFrom < selTo
const fullContent = view.state.doc.sliceString(braceFrom, braceTo)
if (hasSelection) {
const selectedText = view.state.doc.sliceString(selFrom, selTo)
// Parse the full cite content into tokens for partial-key expansion
const tokens = parseTokens(fullContent)
// Relative selection offsets within the cite content
const relSelFrom = selFrom - braceFrom
const relSelTo = selTo - braceFrom
// Find tokens overlapping the selection and expand boundaries
let expandedFrom = relSelFrom
let expandedTo = relSelTo
const selectedTokenValues: string[] = []
for (const token of tokens) {
if (token.end > relSelFrom && token.start < relSelTo) {
expandedFrom = Math.min(expandedFrom, token.start)
expandedTo = Math.max(expandedTo, token.end)
selectedTokenValues.push(token.value)
}
}
window.dispatchEvent(
new CustomEvent('reference:openPicker', {
detail: {
braceFrom,
braceTo,
insertFrom: braceFrom + expandedFrom,
insertTo: braceFrom + expandedTo,
selectedTokens: selectedTokenValues,
},
})
)
} else {
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: insertPos,
insertTo: insertPos,
selectedTokens: [],
},
})
)
}
return true
}
} catch {
// fall through to default completion
}
return startCompletion(view)
}
export const extension = (): Extension =>
Prec.highest(
keymap.of([
{ key: 'Ctrl-Space', run: openPickerIfInCite },
{ key: 'Alt-Space', run: startCompletion },
])
)
@@ -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}
/>
)
}
@@ -8,7 +8,6 @@ import {
} from '@/shared/components/ol/ol-modal' } from '@/shared/components/ol/ol-modal'
import OLFormGroup from '@/shared/components/ol/ol-form-group' import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control' 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 OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col' import OLCol from '@/shared/components/ol/ol-col'
@@ -20,6 +19,42 @@ import type { Bib2JsonEntry } from '@/features/ide-react/references/types'
type FocusArea = 'search' | 'list' | 'footer' 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[]) { function highlight(text: string, tokens: string[]) {
if (!text || tokens.length === 0) return text if (!text || tokens.length === 0) return text
@@ -72,70 +107,75 @@ export default function ReferencePickerModal({
const { searchLocalReferences } = useReferencesContext() const { searchLocalReferences } = useReferencesContext()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [selectedKeys, setSelectedKeys] = useState<string[]>([]) const [selectedKeys, setSelectedKeys] = useState<string[]>(initialKeys)
const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([]) type Hit = { _source: Bib2JsonEntry }
const [knownKeys, setKnownKeys] = useState<Set<string>>(() => new Set()) 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 pendingInitialKeysRef = useRef<string[]>([]) const [drag, setDrag] = useState<BadgeDrag | null>(null)
const requestIdRef = useRef(0) const [dropTarget, setDropTarget] = useState<number | null>(null)
const searchFnRef = useRef(searchLocalReferences)
useEffect(() => { function startDrag(event: React.DragEvent, drag: BadgeDrag) {
searchFnRef.current = searchLocalReferences const { key, from } = drag
}, [searchLocalReferences]) 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( const tokens = useMemo(
() => query.toLowerCase().trim().split(/\s+/).filter(Boolean), () => query.toLowerCase().trim().split(/\s+/).filter(Boolean),
[query] [query]
) )
const searchFnRef = useRef(searchLocalReferences)
useEffect(() => { useEffect(() => {
if (!show) return searchFnRef.current = searchLocalReferences
}, [searchLocalReferences])
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)
const known = new Set(r.hits.map(h => h._source.EntryKey))
setKnownKeys(known)
if (pendingInitialKeysRef.current.length > 0) {
setSelectedKeys([...pendingInitialKeysRef.current])
pendingInitialKeysRef.current = []
}
}
perform()
}, [show, initialKeys])
useEffect(() => { useEffect(() => {
if (!show) return let active = true
Promise.resolve(searchFnRef.current('')).then(response => {
if (active) setAllResults(response.hits)
})
return () => { active = false }
}, [])
const requestId = ++requestIdRef.current useEffect(() => {
if (!search) return
const perform = async () => { let active = true
const r = await searchFnRef.current(query.trim() || '') Promise.resolve(searchFnRef.current(search)).then(response => {
if (active) setFilteredResults(response.hits)
if (requestId !== requestIdRef.current) return })
return () => { active = false }
setResults(r.hits) }, [search])
}
perform()
}, [query])
const filteredKeys = results.map(r => r._source.EntryKey) const filteredKeys = results.map(r => r._source.EntryKey)
@@ -143,7 +183,7 @@ export default function ReferencePickerModal({
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 cancelButtonRef = useRef<HTMLButtonElement | null>(null) const cancelButtonRef = useRef<HTMLButtonElement | null>(null)
const insertButtonRef = useRef<HTMLButtonElement | null>(null) const saveButtonRef = useRef<HTMLButtonElement | null>(null)
const scrollToReference = useCallback((key: string) => { const scrollToReference = useCallback((key: string) => {
const index = results.findIndex( const index = results.findIndex(
@@ -173,10 +213,6 @@ const scrollToReference = useCallback((key: string) => {
}) })
}, []) }, [])
const addOne = useCallback((key: string) => {
setSelectedKeys(prev => [...prev, key])
}, [])
const toggleKey = useCallback((key: string) => { const toggleKey = useCallback((key: string) => {
setSelectedKeys(prev => { setSelectedKeys(prev => {
const index = prev.indexOf(key) const index = prev.indexOf(key)
@@ -251,7 +287,7 @@ const scrollToReference = useCallback((key: string) => {
} }
if (focusArea === 'footer') { if (focusArea === 'footer') {
if (document.activeElement === cancelButtonRef.current) { if (document.activeElement === cancelButtonRef.current) {
insertButtonRef.current?.focus() saveButtonRef.current?.focus()
return return
} else { } else {
setFocusArea('search') setFocusArea('search')
@@ -266,7 +302,7 @@ const scrollToReference = useCallback((key: string) => {
if (focusArea === 'search') { if (focusArea === 'search') {
setFocusArea('footer') setFocusArea('footer')
setFocusedIndex(null) setFocusedIndex(null)
insertButtonRef.current?.focus() saveButtonRef.current?.focus()
return return
} }
if (focusArea === 'list') { if (focusArea === 'list') {
@@ -276,7 +312,7 @@ const scrollToReference = useCallback((key: string) => {
return return
} }
if (focusArea === 'footer') { if (focusArea === 'footer') {
if (document.activeElement === insertButtonRef.current) { if (document.activeElement === saveButtonRef.current) {
cancelButtonRef.current?.focus() cancelButtonRef.current?.focus()
return return
} else { } else {
@@ -298,8 +334,14 @@ const scrollToReference = useCallback((key: string) => {
} }
}, [focusArea, focusedIndex]) }, [focusArea, focusedIndex])
const previewKeys =
drag && drag.from === null && dropTarget !== null
? [...selectedKeys, drag.key]
: selectedKeys
const sourceIndex = drag ? drag.from ?? selectedKeys.length : null
return ( return (
<OLModal show={show} onHide={onClose} size="lg" themed="true"> <OLModal show={show} onHide={onClose} size="lg" themed>
<OLModalHeader> <OLModalHeader>
<OLModalTitle>{t('references_picker_title')}</OLModalTitle> <OLModalTitle>{t('references_picker_title')}</OLModalTitle>
</OLModalHeader> </OLModalHeader>
@@ -322,10 +364,37 @@ const scrollToReference = useCallback((key: string) => {
</OLRow> </OLRow>
<OLRow> <OLRow>
<div className="selected-key-tags"> <div
{selectedKeys.map((key, idx) => ( 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 <span
key={`${key}-${idx}`} 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={ className={
knownKeys.has(key) knownKeys.has(key)
? 'existing-key-tag' ? 'existing-key-tag'
@@ -356,6 +425,27 @@ const scrollToReference = useCallback((key: string) => {
key={key} key={key}
id={`reference-picker-item-${index}`} id={`reference-picker-item-${index}`}
tabIndex={-1} 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={() => { onClick={() => {
setFocusArea('list') setFocusArea('list')
setFocusedIndex(index) setFocusedIndex(index)
@@ -370,7 +460,7 @@ const scrollToReference = useCallback((key: string) => {
<span className="hit-title"> <span className="hit-title">
{highlight(title, tokens)} {highlight(title, tokens)}
</span> </span>
<span className="float-end"> <span className="float-end reference-result-key">
{highlight(key, tokens)} {highlight(key, tokens)}
</span> </span>
</OLCol> </OLCol>
@@ -402,8 +492,8 @@ const scrollToReference = useCallback((key: string) => {
<OLButton variant="secondary" onClick={onClose} ref={cancelButtonRef}> <OLButton variant="secondary" onClick={onClose} ref={cancelButtonRef}>
{t('cancel')} {t('cancel')}
</OLButton> </OLButton>
<OLButton variant="primary" onClick={handleApply} ref={insertButtonRef}> <OLButton variant="primary" onClick={handleApply} ref={saveButtonRef}>
{initialKeys.length ? t('search_replace') : t('insert')} {t('apply')}
</OLButton> </OLButton>
</OLModalFooter> </OLModalFooter>
</div> </div>
@@ -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])
}
@@ -1,7 +1,8 @@
:root { :root {
--badge-bg-color-existing: var(--ds-color-green-900); --badge-bg-color-existing: var(--ds-color-green-900);
--badge-bg-color-nonexistent: var(--ds-color-red-950);
--badge-bg-color-existing-hover: var(--ds-color-green-800); --badge-bg-color-existing-hover: var(--ds-color-green-800);
--badge-bg-color-nonexistent: var(--ds-color-red-950);
--badge-bg-color-nonexistent-hover: var(--ds-color-red-900);
--badge-close-color: var(--content-primary-dark); --badge-close-color: var(--content-primary-dark);
--badge-close-bg-color-hover: var(--bg-dark-tertiary); --badge-close-bg-color-hover: var(--bg-dark-tertiary);
--search-result-at-focus-bg-color: var(--ds-color-teal-950); --search-result-at-focus-bg-color: var(--ds-color-teal-950);
@@ -9,8 +10,9 @@
@include theme('light') { @include theme('light') {
--badge-bg-color-existing: var(--ds-color-green-50); --badge-bg-color-existing: var(--ds-color-green-50);
--badge-bg-color-nonexistent: var(--ds-color-red-50);
--badge-bg-color-existing-hover: var(--ds-color-green-100); --badge-bg-color-existing-hover: var(--ds-color-green-100);
--badge-bg-color-nonexistent: var(--ds-color-red-50);
--badge-bg-color-nonexistent-hover: var(--ds-color-red-100);
--badge-close-color: var(--content-primary); --badge-close-color: var(--content-primary);
--badge-close-bg-color-hover: var(--bg-light-tertiary); --badge-close-bg-color-hover: var(--bg-light-tertiary);
--search-result-at-focus-bg-color: var(--ds-color-teal-50); --search-result-at-focus-bg-color: var(--ds-color-teal-50);
@@ -38,11 +40,24 @@
.selected-key-tags .nonexistent-key-tag .badge-tag { .selected-key-tags .nonexistent-key-tag .badge-tag {
background-color: var(--badge-bg-color-nonexistent) !important; background-color: var(--badge-bg-color-nonexistent) !important;
} }
.selected-key-tags [draggable='true'] .badge-tag-content-btn {
cursor: grab;
user-select: none;
}
.selected-key-tags [data-drag-source='row'] > *,
.selected-key-tags [data-drop-slot] > * {
visibility: hidden;
}
.selected-key-tags [data-drop-slot] {
border-radius: var(--border-radius-base);
outline: 1px solid var(--border-divider-themed);
outline-offset: -1px;
}
.existing-key-tag .badge-tag-content-btn:hover { .existing-key-tag .badge-tag-content-btn:hover {
background-color: var(--badge-bg-color-existing-hover); background-color: var(--badge-bg-color-existing-hover);
} }
.nonexistent-key-tag .badge-tag-content-btn:hover { .nonexistent-key-tag .badge-tag-content-btn:hover {
background-color: transparent; background-color: var(--badge-bg-color-nonexistent-hover);
} }
.badge-close { .badge-close {
color: var(--badge-close-color); color: var(--badge-close-color);
@@ -59,7 +74,14 @@
max-height: calc(100vh - 330px); max-height: calc(100vh - 330px);
overflow-y: auto; overflow-y: auto;
} }
.reference-result-key.drag-image {
padding: 2px 8px;
border-radius: var(--border-radius-base);
background-color: var(--badge-bg-color-existing);
font-size: var(--font-size-02);
}
.search-result-hit { .search-result-hit {
cursor: grab;
&:focus { &:focus {
background: var(--search-result-at-focus-bg-color); background: var(--search-result-at-focus-bg-color);
outline: none; outline: none;
@@ -71,3 +93,22 @@
} }
} }
} }
.cm-tooltip.reference-picker-shortcut-hint {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-02);
padding: 0.25rem 0.5rem;
> button {
align-self: center;
font-size: var(--font-size-01);
}
.reference-picker-shortcut-message {
display: flex;
align-items: flex-start;
gap: var(--spacing-02);
}
}