- {selectedKeys.map((key, idx) => (
+
{
+ 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) => (
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(
+ '.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) => {
{highlight(title, tokens)}
-
+
{highlight(key, tokens)}
@@ -402,8 +492,8 @@ const scrollToReference = useCallback((key: string) => {
{t('cancel')}
-
- {initialKeys.length ? t('search_replace') : t('insert')}
+
+ {t('apply')}
diff --git a/services/web/modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts b/services/web/modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts
new file mode 100644
index 0000000000..df290f2456
--- /dev/null
+++ b/services/web/modules/reference-picker/frontend/js/extensions/reference-picker-keybinding.ts
@@ -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
()
+
+export const citationHint = StateField.define({
+ 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 },
+ ])
+ ),
+]
diff --git a/services/web/modules/reference-picker/frontend/js/hooks/use-citation-hint.ts b/services/web/modules/reference-picker/frontend/js/hooks/use-citation-hint.ts
new file mode 100644
index 0000000000..706a32bb00
--- /dev/null
+++ b/services/web/modules/reference-picker/frontend/js/hooks/use-citation-hint.ts
@@ -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])
+}
diff --git a/services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts b/services/web/modules/reference-picker/frontend/js/reference-index/advanced-reference-index.ts
similarity index 100%
rename from services/web/modules/reference-picker/frontend/reference-index/advanced-reference-index.ts
rename to services/web/modules/reference-picker/frontend/js/reference-index/advanced-reference-index.ts
diff --git a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss b/services/web/modules/reference-picker/frontend/stylesheets/reference-picker.scss
similarity index 63%
rename from services/web/modules/reference-picker/frontend/styles/reference-picker.scss
rename to services/web/modules/reference-picker/frontend/stylesheets/reference-picker.scss
index 72e2fbd581..5a656c6966 100644
--- a/services/web/modules/reference-picker/frontend/styles/reference-picker.scss
+++ b/services/web/modules/reference-picker/frontend/stylesheets/reference-picker.scss
@@ -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);
+ }
+}