Skip to content

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

  1. Host app owns the reference list (from your API, CSL store, and so on).
  2. Editor emits events (editor-change, editor-selection-change) whenever the document or selection changes.
  3. 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