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
appendTransactionruns 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
dispatchskips its own mutation, so calling the composed command withoutdispatchprobes 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
- Commands: Core: marks, attributes, node types
- Commands: Formatting: alignment, indentation, spacing