Custom reference integration¶
Many deployments need to display a local catalog of references, highlight citations as authors select them, and sync back to an external service. This guide distills the moving parts so you can recreate the pattern in your own UI.
Data flow¶
- Host app owns the reference list (from your API, CSL store, and so on).
- Editor emits events (
editor-change,editor-selection-change) whenever the document or selection changes. - Sidebar components render references, highlight matches, and dispatch drag/drop or click events back to the editor.
Step-by-step¶
1. Render your reference panel¶
Create a list component (plain DOM or framework) that exposes two methods:
render(references: Array<{ id: string; rawReference: string; mimeType?: string }>)highlight(ids: string[])
Start with static markup and evolve it as needed; no special helpers are required from the package.
2. Listen for document updates¶
editor.addEventListener('editor-change', (event) => {
const { doc, references } = event.detail;
latestDoc = doc;
panel.render(references ?? resolveReferences(doc));
});
resolveReferences is your hook to fetch or derive metadata (CSL JSON, raw citations, and so on) when you do not supply a references array alongside the snapshot.
3. Highlight references based on selection¶
Listen to editor-selection-change, extract citation IDs from the selection range, and pass them into your panel. Use getCitedReferenceIds from the library instead of walking the document manually:
import { getCitedReferenceIds } from '@sciflow/editor-start';
editor.addEventListener('editor-selection-change', (event) => {
const { from, to } = event.detail;
const doc = editor.document; // live ProseMirror doc
const ids = (from != null && to != null && from !== to)
? getCitedReferenceIds(doc, from, to)
: getCitedReferenceIds(doc);
panel.highlight(ids);
});
For a complete reference list example (including drag payloads), see sciflow-reference-list.
3A. jump to / cycle through a reference's citations (the reverse flow)¶
The previous step goes citation-under-cursor → reference-list row. The reverse — a reference
selected in your own panel → jump to (and cycle through) each place it's cited in the body — uses
findCitationsByReferenceId and the selectCitation command:
import { findCitationsByReferenceId } from '@sciflow/editor-start';
// Re-read the live doc and locate every citation for this reference. Do this on demand
// (e.g. right before jumping), not once and cached — positions shift as the document edits.
function citationSpansFor(referenceId) {
const doc = editor.document; // live ProseMirror doc
return findCitationsByReferenceId(doc, referenceId);
}
// Keep only the selected reference's *id* and a cycle index between clicks — never the
// spans themselves. Positions belong to the document they were computed against; an edit
// between two clicks shifts every position after it.
let selectedReferenceId = null;
let cursor = -1;
function selectReference(referenceId) {
selectedReferenceId = referenceId;
cursor = -1;
jumpNext();
}
function jumpNext() {
if (selectedReferenceId == null) return;
const spans = citationSpansFor(selectedReferenceId); // recomputed on every action
if (spans.length === 0) return;
cursor = (cursor + 1) % spans.length;
editor.commands?.commands?.selectCitation?.(spans[cursor].from);
}
findCitationsByReferenceId returns CitationLocation[]. Each entry contains only
{ from, to } — the document offsets of one citation node. It carries no node reference, so
there is nothing in the result that stays meaningful across an edit; re-derive it, as above.
A single citation node's source can encode multiple reference ids. For example, dragging one
reference onto another produces the merged citation [id1; id2]. findCitationsByReferenceId
matches a node if the given id appears anywhere in its source list, not only when it's the sole
id, so a merged citation is never under-counted.
selectCitation sets a NodeSelection on the citation node and scrolls it into view; no separate
highlight/decoration mechanism is needed to "mark" it — the citation node view already toggles
.ProseMirror-selectednode on selection (styled via the same --sciflow-editor-selectednode
custom property used elsewhere), so a normal NodeSelection is already the visible marker.
Re-resolve the document, don't cache positions across an async gap
Recompute citationSpansFor(referenceId) from editor.document right before calling
selectCitation (or at least right before dispatching, if you're batching a UI update) —
don't capture the document/spans once and reuse them after an await (a metadata lookup, an
animation frame, a network round-trip). selectCitation itself always re-resolves
state.doc at call time, but the position you hand it is only valid for the doc it was
computed against; a doc edit that lands during the gap shifts every position after it.
4. Support drag-and-drop insertion¶
Use a consistent drag payload so the citation feature can consume it automatically:
transfer.setData('application/x-sciflow-reference', 'sidebar');
transfer.setData('application/json', JSON.stringify({
type: 'reference',
id: reference.id,
text: reference.rawReference,
}));
The citation feature automatically consumes this payload via the drop handler in packages/editor/core/src/lib/features/citation/index.ts, calling runInsertCitation. Reuse the same MIME types so no extra work is required in the editor.
Drop behavior is context-aware:
- Dropping onto empty space inserts a new citation at the current insertion point.
- Dropping onto an existing parenthesized or bracketed expression converts the full expression to a citation (for example
(Muller, 2025)or[222; 123]). - Dropping onto regular prose converts only the word under the drop target.
- Dropping onto an existing citation augments citation source metadata; it does not replace the citation's visible text.
Optional: programmatic insertion¶
If you prefer buttons or menus to drag-and-drop:
const insertCitation = editor.commands?.commands?.insertCitation;
if (insertCitation) {
insertCitation({
items: [{ id: 'reference-1' }],
text: '[1]',
style: 'apa',
});
}
Async lookups
Resolve metadata (authors, titles) before calling the command. The editor only stores what you send in text plus whatever you include in the node attributes.
Ghost cursor¶
When the user moves focus to a sidebar panel (for example, to click Cite), the editor's blinking text cursor disappears. The ghost cursor feature solves this by keeping a visual marker at the last known cursor position whenever the editor is unfocused.
Enabling ghost cursor¶
ghostCursorFeature is exported from @sciflow/editor-start. Add it to your feature list:
import {
ghostCursorFeature,
citationFeature,
// ...other features
} from '@sciflow/editor-start';
await editor.configureFeatures([
citationFeature,
// ...
ghostCursorFeature,
]);
Once active, the feature:
- Renders a pulsing blue caret at the collapsed cursor position when the editor loses focus.
- Renders a blue highlight over the selected range when the editor loses focus with a text selection.
- Removes both decorations the moment the editor regains focus.
No extra CSS is required — the styles ship with @sciflow/editor-start.
How it works with cursor-aware buttons¶
The ghost cursor pairs with the cursorActive property on sciflow-reference-list (and the outline's Insert ref buttons). The editor preserves its ProseMirror selection state even when the DOM focus is elsewhere, so insertCitation will still land at the right position as long as cursorActive was set to true before the user moved to the sidebar.
User clicks in editor → editor-selection-change → cursorActive = true
User clicks sidebar button → mousedown preventDefault keeps cursor alive
editor loses DOM focus
ghostCursorFeature shows blue caret
User clicks Cite → insertCitation() fires at saved position
User clicks Insert ref → insertCrossReference() fires at saved position
New document loads → editor-ready → cursorActive = false