Skip to Content
CommandsCommands: Composing

Commands: Composing

Every command builds and dispatches its own transaction, so running several in a row gives several undo steps, each built from a state the previous one has already replaced. composeCommands and sequenceCommands run a group against one transaction instead.

import { composeCommands, sequenceCommands } from "@stesura/core/commands";

composeCommands

Runs every command, dispatches once, and succeeds if any of them did something. A command that returns false keeps whatever it already wrote.

import { composeCommands, resetAlignment, resetIndent, resetSpacing } from "@stesura/core/commands"; const resetAll = composeCommands(resetAlignment(), resetIndent(), resetSpacing()); resetAll(view.state, view.dispatch);

Use it when the steps are independent and a refusal is not a failure.

This is not chainCommands from prosemirror-commands, which stops at the first command that succeeds.

sequenceCommands

Runs the commands in order and stops at the first that returns false, dispatching nothing. The group is all-or-nothing.

import { sequenceCommands, setNode, setTextAlign } from "@stesura/core/commands"; // Center only if the block could become a heading. const centeredTitle = sequenceCommands( setNode("heading", { level: 1, styleId: "Heading1" }), setTextAlign("center") );

Use it when a later step only makes sense if the earlier ones applied.

What a composed step sees

Each command receives the document as the previous commands left it: state.doc, state.selection and state.storedMarks all read through the shared transaction, so position shifts from earlier steps are already applied.

Two limits apply to any composed group:

  • Plugin state is as of the start of the group. A step can’t see plugin state derived from an earlier step, and appendTransaction runs once, when the group is dispatched. If a command depends on a plugin reacting to an earlier one, dispatch them separately.
  • A dry run judges every step against the starting document. A command called without dispatch skips its own mutation, so calling the composed command without dispatch probes each member as though the earlier ones had not run. This keeps toolbar enabled-state checks cheap.

Writing composable commands

A command written the ordinary way composes correctly:

const myCommand: Command = (state, dispatch) => { const tr = state.tr; // …mutate tr… dispatch?.(tr); return true; };

Inside a composed group state.tr is the shared transaction, and the runner absorbs the dispatch call. Don’t hold a transaction across calls, or read state.tr twice expecting two independent transactions.

Next Steps

Last updated on