Skip to content

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 and run() commits the accumulated changes.
  • Inspection helpers (runner.available()) perform dry-runs. The returned object mirrors the command names and exposes flow() 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.dispatch is undefined (e.g. from runner.available()): the command must only test applicability and return true or false. No side effects, no transaction dispatch, no DOM or async work that changes state.
  • When props.dispatch is provided: perform the mutation (build a transaction, call dispatch(tr)), then return true/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.dispatch through from the wrapper to your runX(state, options, dispatch) function.
  • In runX, use if (dispatch) { ...; dispatch(tr); } return true; (or return false when not applicable).
  • Keep applicability logic in one place so dry-run and execution stay in sync.

Don't:

  • Return false from 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: registerCommands now scopes commands to the editor instance that is currently being configured. Call it from inside a feature's initialize() hook (or anywhere that is run while Editor.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.