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:
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():
- Applies the transaction to ProseMirror state
- Reads
tr.getMeta(figureFilesKey)andtr.getMeta(referenceListKey)for metadata changes - Merges new metadata into the snapshot via
upsertFiles()/upsertReferences() - Filters files to match document figure sources (removes orphaned files)
- Updates
snapshot.version(increments only on doc changes, not metadata-only) - 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:
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: