diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs index 1859f1bb9b..1218b3c736 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.mjs +++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs @@ -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', diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 3020f15c81..1d4f319670 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -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: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4ed000b6bb..937c273bb2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/stylesheets/modules/all.scss b/services/web/frontend/stylesheets/modules/all.scss index 346258022e..1bb471327b 100644 --- a/services/web/frontend/stylesheets/modules/all.scss +++ b/services/web/frontend/stylesheets/modules/all.scss @@ -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'; diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 6082d7ae0c..113e353a7b 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -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", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 851e484e44..4cd444594f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 29addc9eac..f1bc5ac233 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -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": "Вы уверены, что хотите отменить расширение __addOnName__?", @@ -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": "Ссылки", diff --git a/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx b/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx deleted file mode 100644 index 6fed369ac2..0000000000 --- a/services/web/modules/reference-picker/frontend/components/reference-picker-controller.tsx +++ /dev/null @@ -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(null) - const [to, setTo] = useState(null) - const [braceFrom, setBraceFrom] = useState(null) - const [braceTo, setBraceTo] = useState(null) - const [initialKeys, setInitialKeys] = useState([]) - - 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 ( - - ) -} diff --git a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts b/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts deleted file mode 100644 index 2b58e8a888..0000000000 --- a/services/web/modules/reference-picker/frontend/extensions/reference-picker-keybinding.ts +++ /dev/null @@ -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 }, - ]) - ) diff --git a/services/web/modules/reference-picker/frontend/js/components/reference-picker-controller.tsx b/services/web/modules/reference-picker/frontend/js/components/reference-picker-controller.tsx new file mode 100644 index 0000000000..117544b024 --- /dev/null +++ b/services/web/modules/reference-picker/frontend/js/components/reference-picker-controller.tsx @@ -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(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 ( + setPicker(null)} + onApply={save} + initialKeys={picker.initialKeys} + /> + ) +} diff --git a/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx b/services/web/modules/reference-picker/frontend/js/components/reference-picker-modal.tsx similarity index 65% rename from services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx rename to services/web/modules/reference-picker/frontend/js/components/reference-picker-modal.tsx index 1940b3429d..91d695c9c4 100644 --- a/services/web/modules/reference-picker/frontend/components/reference-picker-modal.tsx +++ b/services/web/modules/reference-picker/frontend/js/components/reference-picker-modal.tsx @@ -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) { + 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([]) - const [results, setResults] = useState<{ _source: Bib2JsonEntry }[]>([]) - const [knownKeys, setKnownKeys] = useState>(() => new Set()) + const [selectedKeys, setSelectedKeys] = useState(initialKeys) + type Hit = { _source: Bib2JsonEntry } + const [allResults, setAllResults] = useState([]) + const [filteredResults, setFilteredResults] = useState([]) + const search = query.trim() + const results = search ? filteredResults : allResults + const knownKeys = useMemo( + () => new Set(allResults.map(hit => hit._source.EntryKey)), + [allResults] + ) - const pendingInitialKeysRef = useRef([]) - const requestIdRef = useRef(0) - const searchFnRef = useRef(searchLocalReferences) + const [drag, setDrag] = useState(null) + const [dropTarget, setDropTarget] = useState(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) { + 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(null) const searchRef = useRef(null) const cancelButtonRef = useRef(null) - const insertButtonRef = useRef(null) + const saveButtonRef = useRef(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 ( - + {t('references_picker_title')} @@ -322,10 +364,37 @@ const scrollToReference = useCallback((key: string) => { -
- {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); + } +}