Files
overleaf-cep/services/web/modules/reference-picker/frontend/js/hooks/use-citation-hint.ts
T
yu-i-i ae07e781ab 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
2026-11-06 14:45:52 +01:00

156 lines
6.0 KiB
TypeScript

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])
}