Command System Overview¶
SciFlow's editor core exposes a command layer via the @sciflow/editor-core/commands entry point. The system centralises command registration and projects three complementary APIs:
- Immediate commands (
runner.commands) execute right away and dispatch their transaction automatically. - Flows (
runner.flow()) create a lazy pipeline; each call queues a command andrun()commits the accumulated changes. - Inspection helpers (
runner.available()) perform dry-runs. The returned object mirrors the command names and exposesflow()so you can compose non-dispatching pipelines.
Command contract: applicability vs execution¶
All feature commands must follow the ProseMirror command contract so the format bar (and any UI) can enable/disable buttons by dry-running commands.
- When
props.dispatchis undefined (e.g. fromrunner.available()): the command must only test applicability and returntrueorfalse. No side effects, no transaction dispatch, no DOM or async work that changes state. - When
props.dispatchis provided: perform the mutation (build a transaction, calldispatch(tr)), then returntrue/false.
Implementations should accept optional dispatch and only call it when provided. See runInsertMath in the math feature as the canonical shape.
Do:
- Pass
props.dispatchthrough from the wrapper to yourrunX(state, options, dispatch)function. - In
runX, useif (dispatch) { ...; dispatch(tr); } return true;(or returnfalsewhen not applicable). - Keep applicability logic in one place so dry-run and execution stay in sync.
Don't:
- Return
falsefrom the wrapper when!props.dispatch; that breaks availability checks and leaves toolbar buttons disabled. - Use a no-op dispatch (e.g.
dispatch = () => {}) just to avoid the guard; omit dispatch so the command can short-circuit without building a transaction.
Exception (interactive commands): Commands that open a picker or modal (e.g. insertFigureInteractive) may return true when !props.dispatch so the toolbar shows the button as enabled without opening UI. Document this explicitly in the command and in the contract comment in commands.ts.
Registering Commands¶
Commands are registered through registerCommands. Each entry is a factory that returns a ProseMirror-compatible command once invoked with its arguments.
Important:
registerCommandsnow scopes commands to the editor instance that is currently being configured. Call it from inside a feature'sinitialize()hook (or anywhere that is run whileEditor.create()is wiring features) so each editor only receives the commands it explicitly enables.
Standard insert command (dry-run safe):
// In feature index: pass props.dispatch through
insertMath: {
command: (options) => (props) => runInsertMath(props.state, options ?? {}, props.dispatch),
meta: { icon: 'functions', label: 'Insert equation', group: 'media', order: 15 },
},
// In command.ts: optional dispatch, guard before mutating
export const runInsertMath = (state, options, dispatch?: CommandDispatch): boolean => {
const mathType = state.schema.nodes.math;
if (!mathType) return false;
// ... applicability checks ...
if (dispatch) {
const tr = state.tr.replaceSelectionWith(mathNode, false);
dispatch(tr.scrollIntoView());
}
return true;
};
Interactive command (exception: return true when !dispatch so button stays enabled):
insertFigureInteractive: {
command: () => (props) => {
// Exception: return true when !dispatch so toolbar shows button enabled without opening picker.
if (!props.dispatch) return true;
void (async () => {
const options = await promptForFigureOptions();
if (options && props.view) runInsertFigure(props.view.state, options, props.view.dispatch.bind(props.view));
})();
return true;
},
meta: { icon: 'image', label: 'Insert figure', group: 'media', order: 10 },
},
Annotation example (availability = schema has mark):
import type { Feature } from '@sciflow/editor-core';
import { Editor } from '@sciflow/editor-core';
import { registerCommands } from '@sciflow/editor-core/commands';
export const annotationCommandsFeature: Feature = {
name: 'annotation-commands',
initialize() {
registerCommands({
annotate: (color: string) => ({ state, dispatch, tr }) => {
if (!dispatch) {
return Boolean(state.schema.marks.highlight);
}
const { from, to } = tr.selection;
dispatch(tr.addMark(from, to, state.schema.marks.highlight.create({ color })));
return true;
},
});
},
};
await Editor.create({
docId: 'doc-123',
sync,
features: [annotationCommandsFeature],
});
Commands become available immediately after registration on all three command surfaces, but only for the editor (and command runner) that executed the feature initialization above.
Using Commands¶
const runner = editor.getCommands();
// Immediate execution
runner.commands.focus();
runner.commands.insertText('Hello world');
// Flow-based pipelines
runner.flow().focus().toggleMark('strong').run();
// Capability checks with optional flow composition
if (runner.available().toggleMark('em')) {
runner.flow().toggleMark('em').run();
}
Inside a command you receive CommandProps, which include the editor, view, state, the mutable transaction, dispatch, and live accessors to commands, flow() and available().
Built-in Commands¶
Out of the box the core package registers:
| Command | Description |
|---|---|
focus |
Focus the current editor view. |
blur |
Remove focus from the editor view. |
insertText |
Insert text at the current selection. |
toggleMark |
Toggle a mark by name or MarkType, with extendEmptyMarkRange support. |
Feature packages register additional commands. For example:
| Command | Feature | Description |
|---|---|---|
insertMath |
math | Insert or wrap selection in a math node (TeX). Mod-m shortcut. |
insertFigure |
figure | Insert a figure with caption. |
insertCitation |
citation | Insert an inline citation node. |
Math shortcuts and input rules¶
- Mod-m: Wrap the current selection in a math node, or insert an empty one. Display style when the selection contains a newline, otherwise inline.
$$+ space: At block start, create an empty display math node.$$...$$: Typing e.g.$$\frac{1}{2}$$converts the content into a display math node.
Feature packages register commands by invoking registerCommands inside their initialize() hook; the editor automatically opens and closes the registration window while it instantiates each feature.