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'
|
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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Ссылки",
|
||||||
|
|||||||
-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'
|
} 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>
|
||||||
+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 {
|
: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user