Skip to content

Commands & Metadata Flow

This page describes how the command system works internally and how metadata (references, files) flows in and out of the editor state. It is aimed at contributors working on features and the editor core.

Command System

Command Contract

Every command follows the ProseMirror convention — a factory that returns a function receiving CommandProps:

type RawCommand = (...args: any[]) => (props: CommandProps) => boolean;

CommandProps provides access to the editor, current state, transaction, and dispatch:

interface CommandProps {
  readonly editor: Editor;
  readonly state: EditorState;          // Current ProseMirror state
  readonly view: EditorView;
  tr: Transaction;                      // Mutable — threaded across flow steps
  dispatch?: (tr: Transaction) => void; // Undefined in dry-run context
  flow(): FlowCommands;
  available(): AvailableCommands;
  readonly commands: SingleCommands;
}

The dispatch field controls execution mode:

dispatch Mode Behavior
Provided Execute Build transaction, call dispatch(tr), return success boolean
undefined Dry-run Test applicability only — no side effects

Three Execution Modes

const runner = editor.getCommands();

// 1. Immediate — execute one command, auto-dispatches
runner.commands.insertCitation({ source: 'ref-1' });

// 2. Flow — batch commands, dispatch once on .run()
runner.flow()
  .insertText('Hello')
  .toggleBold()
  .run();

// 3. Inspection — dry-run, returns true/false
const canBold = runner.available().toggleBold();

Flow batches multiple commands into a single transaction. Each step captures the current tr via getter/setter, and .run() dispatches once if all steps succeeded. This avoids multiple renders for compound operations.

Inspection (available()) passes dispatch = undefined, so commands return true/false without modifying state. The format bar uses this to enable/disable buttons.

Registering Commands

Features register commands during initialization via registerCommands():

// packages/editor/core/src/lib/features/citation/index.ts
initialize() {
  registerCommands({
    insertCitation:
      (options: InsertCitationOptions) =>
      (props) => runInsertCitation(props.state, options, props.dispatch),

    addReference:
      (reference) =>
      (props) => runAddReference(props.state, reference, props.dispatch),
  });
}

Registration happens inside withCommandRegistration(editor, callback), which pushes the editor onto a stack so registerCommands knows which registry to write to. Each editor instance has its own isolated command registry.

Base Commands

11 built-in commands are available on every editor, regardless of features:

focus, blur, undo, redo, scrollIntoView, setSelection, insertText, setNodeAttrs, and a few more. These are defined in BASE_COMMANDS in commands.ts.

Web Component Access

The <sciflow-editor> element exposes commands via a getter:

const el = document.querySelector('sciflow-editor');
el.commands.commands.focus();
el.commands.flow().insertText('Hello').run();

Metadata Flow: Files & References

SciFlow stores document content inside ProseMirror (the node tree) and metadata (files, references) outside it in the editor's snapshot. Plugin state bridges the two.

Storage Architecture

┌─────────────────────────────────────────────┐
│ Editor Snapshot                              │
│                                              │
│   doc: SciFlowDocJSON        (PM doc tree)   │
│   files: SnapshotFile[]      (metadata)      │
│   references: SnapshotReference[] (metadata) │
│   selection: SelectionJSON                   │
│   version: number                            │
└─────────────────────────────────────────────┘

The files and references arrays are not part of the ProseMirror document. They travel alongside it in the snapshot.

Plugin State as Bridge

Two ProseMirror plugins mediate between transactions and the snapshot:

Figure Files Plugin (figureFilesKey):

// Plugin state: SnapshotFile[]
const figureFilesKey = new PluginKey<SnapshotFile[]>('figureFiles');

// apply():
// 1. Check tr.getMeta(figureFilesKey) for new files
// 2. Merge via upsert (by ID)
// 3. Re-collect figure src from doc to stay in sync

Reference List Plugin (referenceListKey):

// Plugin state: SnapshotReference[]
const referenceListKey = new PluginKey<SnapshotReference[]>('referenceList');

// apply():
// 1. Check tr.getMeta(referenceListKey) for new references
// 2. Merge via upsert (by ID)
// 3. Supports __replace flag for full list replacement (used on snapshot load)

Loading: Snapshot → Editor

When the editor mounts, files and references are seeded into plugin state via transaction metadata:

Snapshot.files ──setMeta(figureFilesKey)──→ Plugin state
Snapshot.references ──setMeta(referenceListKey)──→ Plugin state

The mount() method dispatches seed transactions immediately after creating the EditorView, ensuring plugins have the initial metadata before any user interaction.

Editing: Transactions → Snapshot

When a user edits (e.g., inserts a citation), handleTransaction():

  1. Applies the transaction to ProseMirror state
  2. Reads tr.getMeta(figureFilesKey) and tr.getMeta(referenceListKey) for metadata changes
  3. Merges new metadata into the snapshot via upsertFiles() / upsertReferences()
  4. Filters files to match document figure sources (removes orphaned files)
  5. Updates snapshot.version (increments only on doc changes, not metadata-only)
  6. Emits to listeners and sync layer
User action
  → Transaction (may carry file/reference meta)
    → handleTransaction()
      → Extract meta from tr
      → Merge into snapshot
      → Emit 'editor-change' event
      → Call sync.applyLocal()

Output: The editor-change Event

The web component packages everything into a single atomic event:

// CustomEvent<SciFlowEditorChangeDetail>
{
  doc: SciFlowDocJSON,          // Updated document
  operations: Operation[],      // ProseMirror steps
  files: SnapshotFile[],        // Current file list
  references: SnapshotReference[] // Current reference list
}

Consumers receive doc + files + references together, so they never persist an inconsistent state.

Collaboration: Sync Layer

The sync layer receives combined operations:

sync.applyLocal([{
  type: 'pm-transaction',
  steps,
  doc,
  selection,
  files,
  references,
}]);

When external changes arrive (from collaborators), updateFromSync() merges files/references into the snapshot and dispatches plugin meta to keep PM plugin state in sync.

Sync limitations

The Yjs adapter does not sync files or references through the CRDT. These must be synced via a separate channel. See Custom Sync Strategy.


Drag-and-Drop Protocol

SciFlow uses custom MIME types on DataTransfer to distinguish drag sources. This lets the editor's drop handlers identify what is being dropped (a reference, a cross-reference, or a file) and route it to the correct feature.

MIME Types

MIME type Source component Target feature Payload
application/x-sciflow-reference <sciflow-reference-list> Citation 'sidebar' (source identifier)
application/x-sciflow-cross-reference <sciflow-outline> Cross-reference 'outline' (source identifier)
application/json Both Both JSON with full entry data
text/plain Both Fallback Human-readable text

Each drag operation sets all three MIME types so the drop target can pick the most specific one, and non-SciFlow drop targets still get usable data.

How It Works

Drag start (in the source component):

// <sciflow-reference-list> sets:
transfer.setData('application/x-sciflow-reference', 'sidebar');
transfer.setData('application/json', JSON.stringify(referenceEntry));
transfer.setData('text/plain', referenceEntry.title);

// <sciflow-outline> sets:
transfer.setData('application/x-sciflow-cross-reference', 'outline');
transfer.setData('application/json', JSON.stringify({ id, href, text }));
transfer.setData('text/plain', headingText);

Drag over (on <sciflow-editor>):

The editor element checks DataTransfer.types to decide whether to allow the drop:

// editor-element.ts — dragover handler
const dominated =
  types.includes('application/x-sciflow-reference') ||
  types.includes('application/x-sciflow-cross-reference');
if (dominated) event.preventDefault(); // allow drop

Drop (in feature drop handlers):

Each feature registers a ProseMirror drop handler that checks for its MIME type:

// Citation feature — checks for reference MIME
const source = event.dataTransfer?.getData('application/x-sciflow-reference');
if (source) {
  const json = event.dataTransfer?.getData('application/json');
  // Parse reference, insert citation at drop position
}

// Cross-reference feature — checks for xref MIME
const source = event.dataTransfer?.getData('application/x-sciflow-cross-reference');
if (source) {
  const json = event.dataTransfer?.getData('application/json');
  // Parse target, insert cross-reference link at drop position
}

Keyboard Alternatives

Every drag source also provides a button that fires a custom event as an accessible alternative:

Component Button Event Payload
<sciflow-outline> Insert button per item sciflow-insert-cross-reference { type, refType, id, href, text }
<sciflow-reference-list> Cite button per item sciflow-insert-citation { reference }

The event payloads mirror the drag data, so host applications can handle both paths with the same logic.

Extending with Custom Drag Sources

To build a custom panel that drops content into the editor, set the appropriate MIME type:

myElement.addEventListener('dragstart', (e) => {
  // For a reference source:
  e.dataTransfer.setData('application/x-sciflow-reference', 'my-panel');
  e.dataTransfer.setData('application/json', JSON.stringify(myReference));

  // For a cross-reference source:
  e.dataTransfer.setData('application/x-sciflow-cross-reference', 'my-panel');
  e.dataTransfer.setData('application/json', JSON.stringify({ id, href: `#${id}`, text }));
});

The editor will accept the drop and route it to the correct feature based on the MIME type.


Detailed Flows

Adding a Reference via Command

Here's the full flow for addReference():

1. Command called: commands.addReference({ id: 'ref-1', rawReference: '...' })
2. Command implementation:
   - Creates transaction: state.tr.setMeta(referenceListKey, [newReference])
   - Calls dispatch(tr)
3. handleTransaction() fires:
   - Reads tr.getMeta(referenceListKey) → [newReference]
   - Merges into snapshot.references via upsertReferences()
4. Snapshot updated, version unchanged (metadata-only)
5. editor-change event dispatched with updated references array
6. sync.applyLocal() called with combined operation

Adding a Figure File

1. User picks a file via insertFigureInteractive()
2. Upload handler returns SnapshotFile with id, url, previewSrc
3. Command creates figure node (with src attr) + dispatches tr with setMeta(figureFilesKey, [file])
4. handleTransaction():
   - Reads tr.getMeta(figureFilesKey) → [file]
   - Merges into snapshot.files
   - Filters to match figure nodes in doc (removes orphaned files)
5. editor-change event dispatched with updated files array

Reading Metadata

From outside the editor:

const el = document.querySelector('sciflow-editor');
el.addEventListener('editor-change', (e) => {
  const { doc, files, references } = e.detail;
  // Persist all three together
});

From inside a plugin or command:

// In a command:
(props) => {
  const files = props.state.plugins
    .find(p => p.key === figureFilesKey)
    ?.getState(props.state);

  // Or via the Editor API:
  const files = props.editor.getFiles();
  const refs = props.editor.getReferences();
}