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:
@@ -4,6 +4,7 @@ import { expressify } from '@overleaf/promise-utils'
|
||||
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
|
||||
|
||||
const VALID_KEYS = [
|
||||
'reference-picker-shortcut',
|
||||
'react-history-buttons-tutorial',
|
||||
'writefull-integration',
|
||||
'writefull-oauth-promotion',
|
||||
|
||||
@@ -1065,7 +1065,7 @@ module.exports = {
|
||||
mainEditorLayoutModals: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
|
||||
'../modules/reference-picker/frontend/js/components/reference-picker-controller.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
@@ -1140,7 +1140,7 @@ module.exports = {
|
||||
autoCompleteExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts'
|
||||
'../modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts'
|
||||
),
|
||||
],
|
||||
sectionTitleGenerators: [],
|
||||
@@ -1194,7 +1194,7 @@ module.exports = {
|
||||
referenceIndices: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts'
|
||||
'../modules/reference-picker/frontend/js/reference-index/advanced-reference-index.ts'
|
||||
),
|
||||
],
|
||||
railEntries: [],
|
||||
|
||||
@@ -1762,6 +1762,8 @@
|
||||
"reference_manager_searched_groups": "",
|
||||
"reference_manager_unlinked": "",
|
||||
"reference_managers": "",
|
||||
"reference_picker_hide_hint": "",
|
||||
"reference_picker_shortcut_hint": "",
|
||||
"reference_search": "",
|
||||
"reference_search_setting": "",
|
||||
"reference_search_results": "",
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
@import '../../../modules/admin-tools/frontend/styles/manage-users-page';
|
||||
@import '../../../modules/admin-tools/frontend/styles/pagination-dark';
|
||||
@import '../../../modules/github-sync/frontend/styles/github-sync';
|
||||
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
|
||||
@import '../../../modules/reference-picker/frontend/stylesheets/reference-picker';
|
||||
|
||||
@@ -1402,6 +1402,8 @@
|
||||
"reference": "Referenz",
|
||||
"reference_error_relink_hint": "Wenn dieser Fehler weiterhin auftritt, versuche dein Konto hier neu zu verlinken:",
|
||||
"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_sync": "Referenzmanager synchronisieren",
|
||||
"references": "Referenzen",
|
||||
|
||||
@@ -2313,6 +2313,8 @@
|
||||
"reference_manager_searched_groups": "__provider__ search groups",
|
||||
"reference_manager_unlinked": "Reference manager unlinked",
|
||||
"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_info_new": "Find your references easily—search by author, title, year, or journal.",
|
||||
"reference_search_results": "Reference search results",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"and": "и",
|
||||
"annual": "Цена за год",
|
||||
"anonymous": "Аноним",
|
||||
"apply": "Применить",
|
||||
"apply_educational_discount_description_with_group_discount": "Получите суммарную скидку 40% для групп, использующих __appName__ в обучении",
|
||||
"are_you_sure_you_want_to_cancel": "Вы уверены, что хотите отменить подписку?",
|
||||
"are_you_sure_you_want_to_cancel_add_on": "Вы уверены, что хотите отменить расширение <strong>__addOnName__</strong>?",
|
||||
@@ -618,6 +619,8 @@
|
||||
"recurly_email_updated": "Адрес электронной почты для счетов успешно обновлён",
|
||||
"reduce_costs_group_licenses": "С нашими групповыми лицензиями со скидкой Вы сократите бумажную работу и расходы.",
|
||||
"reference": "Ссылка",
|
||||
"reference_picker_hide_hint": "Больше не показывать",
|
||||
"reference_picker_shortcut_hint": "Нажмите __shortcut__, чтобы найти ссылки на источники.",
|
||||
"reference_search": "Расширенный поиск по источникам",
|
||||
"reference_sync": "Синхронизация с менеджером библиографии",
|
||||
"references": "Ссылки",
|
||||
|
||||
-92
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
-126
@@ -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 },
|
||||
])
|
||||
)
|
||||
+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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+156
-66
@@ -8,7 +8,6 @@ import {
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
@@ -20,6 +19,42 @@ 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
|
||||
|
||||
@@ -72,70 +107,75 @@ export default function ReferencePickerModal({
|
||||
const { searchLocalReferences } = useReferencesContext()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||
const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([])
|
||||
const [knownKeys, setKnownKeys] = useState<Set<string>>(() => new Set())
|
||||
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 pendingInitialKeysRef = useRef<string[]>([])
|
||||
const requestIdRef = useRef(0)
|
||||
const searchFnRef = useRef(searchLocalReferences)
|
||||
const [drag, setDrag] = useState<BadgeDrag | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
searchFnRef.current = searchLocalReferences
|
||||
}, [searchLocalReferences])
|
||||
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(() => {
|
||||
if (!show) return
|
||||
|
||||
requestIdRef.current++
|
||||
|
||||
setQuery('')
|
||||
setSelectedKeys([])
|
||||
setResults([])
|
||||
|
||||
pendingInitialKeysRef.current = [...initialKeys]
|
||||
|
||||
const requestId = ++requestIdRef.current
|
||||
|
||||
const perform = async () => {
|
||||
const r = await searchFnRef.current('')
|
||||
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
setResults(r.hits)
|
||||
|
||||
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])
|
||||
searchFnRef.current = searchLocalReferences
|
||||
}, [searchLocalReferences])
|
||||
|
||||
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 () => {
|
||||
const r = await searchFnRef.current(query.trim() || '')
|
||||
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
setResults(r.hits)
|
||||
}
|
||||
|
||||
perform()
|
||||
}, [query])
|
||||
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)
|
||||
|
||||
@@ -143,7 +183,7 @@ export default function ReferencePickerModal({
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement | 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 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) => {
|
||||
setSelectedKeys(prev => {
|
||||
const index = prev.indexOf(key)
|
||||
@@ -251,7 +287,7 @@ const scrollToReference = useCallback((key: string) => {
|
||||
}
|
||||
if (focusArea === 'footer') {
|
||||
if (document.activeElement === cancelButtonRef.current) {
|
||||
insertButtonRef.current?.focus()
|
||||
saveButtonRef.current?.focus()
|
||||
return
|
||||
} else {
|
||||
setFocusArea('search')
|
||||
@@ -266,7 +302,7 @@ const scrollToReference = useCallback((key: string) => {
|
||||
if (focusArea === 'search') {
|
||||
setFocusArea('footer')
|
||||
setFocusedIndex(null)
|
||||
insertButtonRef.current?.focus()
|
||||
saveButtonRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (focusArea === 'list') {
|
||||
@@ -276,7 +312,7 @@ const scrollToReference = useCallback((key: string) => {
|
||||
return
|
||||
}
|
||||
if (focusArea === 'footer') {
|
||||
if (document.activeElement === insertButtonRef.current) {
|
||||
if (document.activeElement === saveButtonRef.current) {
|
||||
cancelButtonRef.current?.focus()
|
||||
return
|
||||
} else {
|
||||
@@ -298,8 +334,14 @@ const scrollToReference = useCallback((key: string) => {
|
||||
}
|
||||
}, [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="true">
|
||||
<OLModal show={show} onHide={onClose} size="lg" themed>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('references_picker_title')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
@@ -322,10 +364,37 @@ const scrollToReference = useCallback((key: string) => {
|
||||
</OLRow>
|
||||
|
||||
<OLRow>
|
||||
<div className="selected-key-tags">
|
||||
{selectedKeys.map((key, idx) => (
|
||||
<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'
|
||||
@@ -356,6 +425,27 @@ const scrollToReference = useCallback((key: string) => {
|
||||
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)
|
||||
@@ -370,7 +460,7 @@ const scrollToReference = useCallback((key: string) => {
|
||||
<span className="hit-title">
|
||||
{highlight(title, tokens)}
|
||||
</span>
|
||||
<span className="float-end">
|
||||
<span className="float-end reference-result-key">
|
||||
{highlight(key, tokens)}
|
||||
</span>
|
||||
</OLCol>
|
||||
@@ -402,8 +492,8 @@ const scrollToReference = useCallback((key: string) => {
|
||||
<OLButton variant="secondary" onClick={onClose} ref={cancelButtonRef}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="primary" onClick={handleApply} ref={insertButtonRef}>
|
||||
{initialKeys.length ? t('search_replace') : t('insert')}
|
||||
<OLButton variant="primary" onClick={handleApply} ref={saveButtonRef}>
|
||||
{t('apply')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</div>
|
||||
+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])
|
||||
}
|
||||
+44
-3
@@ -1,7 +1,8 @@
|
||||
:root {
|
||||
--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-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-bg-color-hover: var(--bg-dark-tertiary);
|
||||
--search-result-at-focus-bg-color: var(--ds-color-teal-950);
|
||||
@@ -9,8 +10,9 @@
|
||||
|
||||
@include theme('light') {
|
||||
--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-nonexistent: var(--ds-color-red-50);
|
||||
--badge-bg-color-nonexistent-hover: var(--ds-color-red-100);
|
||||
--badge-close-color: var(--content-primary);
|
||||
--badge-close-bg-color-hover: var(--bg-light-tertiary);
|
||||
--search-result-at-focus-bg-color: var(--ds-color-teal-50);
|
||||
@@ -38,11 +40,24 @@
|
||||
.selected-key-tags .nonexistent-key-tag .badge-tag {
|
||||
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 {
|
||||
background-color: var(--badge-bg-color-existing-hover);
|
||||
}
|
||||
.nonexistent-key-tag .badge-tag-content-btn:hover {
|
||||
background-color: transparent;
|
||||
background-color: var(--badge-bg-color-nonexistent-hover);
|
||||
}
|
||||
.badge-close {
|
||||
color: var(--badge-close-color);
|
||||
@@ -59,7 +74,14 @@
|
||||
max-height: calc(100vh - 330px);
|
||||
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 {
|
||||
cursor: grab;
|
||||
&:focus {
|
||||
background: var(--search-result-at-focus-bg-color);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user