Lesson 04 — The Command Pattern
The most important rule in the Affinity SDK is this: you cannot change anything by setting a property directly. Every modification — moving a node, changing a colour, replacing text — must go through a command that you build and execute. This lesson explains why that is, and how to use the command pattern effectively.
This design gives you undo/redo for free. Every command you execute appears in the undo stack exactly as if the user had made the change manually. It also means scripts are safe — you can build a compound command, inspect it, and only commit it when you are ready. Getting fluent with DocumentCommand, CompoundCommandBuilder, and executeCommand is what separates scripts that feel like first-class Affinity operations from ones that feel like hacks.
What this lesson covers
- Why every change goes through
executeCommand() DocumentCommandstatic factoriesCompoundCommandBuilder— batching changes into one undo stepAddChildNodesCommandBuilder— creating new nodes- Preview mode
- Async command execution
The golden rule
Never mutate nodes directly. Always build a command and execute it.
This is how Affinity gives you undo/redo for free. Every user action in the UI is a command. Your scripts are no different.
// Snippet
// WRONG — direct mutation (won't work, and has no undo)
node.fill = someColour;
// CORRECT — command pattern (selection first, then fill descriptor)
const cmd = DocumentCommand.createSetBrushFill(selection, fillDescriptor);
doc.executeCommand(cmd);
Basic command execution
Build a command with a DocumentCommand factory, then pass it to doc.executeCommand().
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection } = require('/selections.js');
const { FillDescriptor } = require('/fills.js');
const { RGBA8 } = require('/colours.js');
const doc = Document.current;
if (!doc) { console.log('No doc.'); }
else {
const sel = doc.selection;
if (sel.nodes.isEmpty) { console.log('Select something first.'); }
else {
const colour = RGBA8(220, 50, 50, 255); // red
const fill = FillDescriptor.createSolid(colour);
const cmd = DocumentCommand.createSetBrushFill(sel, fill);
doc.executeCommand(cmd);
console.log('Fill applied.');
}
}
CompoundCommandBuilder — one undo step for many changes
When your script makes several changes, wrap them in a compound command so the user can undo everything in one step.
'use strict';
const { Document } = require('/document.js');
const { CompoundCommandBuilder, DocumentCommand } = require('/commands.js');
const { FillDescriptor } = require('/fills.js');
const { RGBA8 } = require('/colours.js');
const doc = Document.current;
const nodes = doc.layers.all.filter(n => n.isShapeNode);
const builder = CompoundCommandBuilder.create();
for (const node of nodes) {
const sel = doc.selection; // or build a selection from node
const colour = RGBA8(0, 120, 200, 255);
const fill = FillDescriptor.createSolid(colour);
const cmd = DocumentCommand.createSetBrushFill(sel, fill);
builder.addCommand(cmd);
}
// Execute all changes as a single undoable action
doc.executeCommand(builder.createCommand());
console.log('All shapes recoloured. One undo step.');
AddChildNodesCommandBuilder — creating new nodes
To add objects to the document, use AddChildNodesCommandBuilder:
'use strict';
const { Document } = require('/document.js');
const { AddChildNodesCommandBuilder } = require('/commands.js');
const Nodes = require('/nodes.js');
const doc = Document.current;
// Get a node definition from the SDK (shapes, text, etc.)
// See Lesson 06 (Geometry) — basic shapes section — for ShapeRectangle, ShapeEllipse, etc.
const builder = AddChildNodesCommandBuilder.create();
builder.addNode(someNodeDefinition);
const cmd = builder.createCommand(
true, // andSelect — select the new node after adding
Nodes.NodeChildType.Main // which child list to add to
);
doc.executeCommand(cmd);
// Access the newly created node
const newNode = cmd.newNodes[0];
console.log('Created:', newNode.descriptionInterface?.description);
Typed add* methods are shorthand for addNode with a specific definition type — same pattern, less setup:
// Snippet — assumes builder and definitions are ready
builder.addShapeNode(shapeNodeDef); // shapes
builder.addImageNode(imageNodeDef); // placed images
builder.addTableTextNode(tableTextDef); // tables
builder.addPatternRasterNode(patternDef); // tiled-pattern raster
builder.addExposureAdjustmentRasterNode(exposureDef); // adjustments…
builder.addGaussianBlurFilterRasterNode(gaussianBlurDef); // …and live filters
// ~50 typed adjustment/filter adds follow the same pattern (BlackAndWhite,
// Posterise, Curves, Vibrance, WhiteBalance, …): addXRasterNode(xDef).
Reading command results
After executing an add-node command, you can access the newly created nodes:
// Snippet — assumes cmd has already been executed via doc.executeCommand(cmd)
doc.executeCommand(cmd);
const newNodes = cmd.newNodes;
for (const n of newNodes) {
console.log('New node:', n.descriptionInterface?.description);
}
Preview mode
Preview mode lets you see the effect of a command without committing it to the undo stack. Useful for interactive tools.
// Snippet — assumes cmd and doc are already defined
// Apply as preview (not committed to history)
doc.executeCommand(cmd, true);
// Clear all previews
doc.executeCommand(DocumentCommand.createClearPreviews());
Async command execution
For long-running scripts or sequences:
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { FillDescriptor } = require('/fills.js');
const { RGBA8 } = require('/colours.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) { console.log('Select something first.'); }
else {
const sel = doc.selection;
const fill = FillDescriptor.createSolid(RGBA8(80, 140, 220, 255));
const cmd = DocumentCommand.createSetBrushFill(sel, fill);
doc.executeCommandAsync(cmd, (errorCode) => {
if (errorCode) {
console.log('Command failed:', errorCode);
} else {
console.log('Done.');
}
});
}
Or with doc.promises for promise-based workflows:
// Snippet — assumes doc is already defined (e.g. const doc = Document.current)
async function applyAndSave() {
const docP = doc.promises;
await docP.executeCommand(cmd);
// Check mustSaveAs before saving — new documents need saveAs(path) first
if (doc.mustSaveAs) {
const { app } = require('/application.js');
await docP.saveAs(app.userDesktopPath + '/' + doc.title + '.af');
} else {
await docP.save();
}
console.log('Saved.');
}
Programmatic selection
Use DocumentCommand.createSetSelection(selection) to select nodes from a script — replaces the manual selection that a user would make with the Move Tool.
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection } = require('/selections.js');
const doc = Document.current;
if (!doc || doc.layers.all.isEmpty) { console.log('No document or no layers.'); }
else {
// Select the first top-level layer
const target = doc.layers.all.first;
const sel = Selection.create(doc, target);
doc.executeCommand(DocumentCommand.createSetSelection(sel));
console.log('Selected:', target.descriptionInterface?.description);
}
To select multiple nodes, create a Selection that includes them all:
// Snippet — assumes doc and several node references exist
const multiSel = Selection.create(doc, node1, node2, node3);
doc.executeCommand(DocumentCommand.createSetSelection(multiSel));
Undo and redo
doc.undo() and doc.redo() step through the history. doc.canUndo and doc.undoDescription let you inspect the undo stack first.
// Snippet — assumes doc is already defined
// Programmatic undo/redo
doc.undo();
doc.redo();
// Check what's available
console.log('Can undo:', doc.canUndo);
console.log('Can redo:', doc.canRedo);
console.log('Next undo:', doc.undoDescription);
Common DocumentCommand factories
| Factory | What it does |
|---|---|
createSetBrushFill(sel, fill) | Set interior fill |
createSetPenFill(sel, fill) | Set stroke fill |
createSetLineStyle(sel, style) | Set stroke style |
createSetVisibility(sel, bool) | Show/hide |
createSetEditable(sel, bool) | Lock/unlock — selection first |
createDeleteSelection(sel) | Delete nodes |
createSelectAll(layerOnly) | Select all |
createSetDescription(sel, str) | Rename layer |
createSetBlendMode(sel, mode) | Blend mode |
createTransform(sel, xf) | Move/scale/rotate |
createSetText(sel, text) | Set text content |
createSetCurrentSpread(spread) | Change active spread |
createSetSelection(sel) | Select nodes programmatically |
Key gotchas
Compound commands = one undo step. If you call executeCommand() in a loop, each call creates a separate undo entry. Use CompoundCommandBuilder to group them.
cmd.newNodes only works after execution. Access the result after doc.executeCommand(cmd) returns.
setCurrentSpreadclears selection. See Lesson 02.
Exercises
Exercise 4.1 — Batch recolour
Select several shapes. Write a script that sets each one to a different colour using a compound command (one undo step for all changes).
Exercise 4.2 — Undo explorer
Write a script that logs the current undo stack size, makes a visible change, then logs the undo description before calling doc.undo().
Exercise 4.3 — Visibility toggle
Write a script that hides all currently visible nodes in the selection, or shows all hidden ones — toggling their state — in a single compound command.
SDK modules used in this lesson
/commands—DocumentCommand,CompoundCommandBuilder,AddChildNodesCommandBuilder/document—Document/nodes—NodeChildType
Further examples
The official Affinity examples show command-based workflows in complete, production-quality scripts:
| Example | What it shows |
|---|---|
addGuides.js | DocumentCommand.createAddGuide, CompoundCommandBuilder, live-preview dialog |
alignToPage.js | DocumentCommand.createSetNodeAlignment, bounding-box computation |
adjustPageItems.js | DocumentCommand batching, Collection.range for per-page iteration |
setDocumentFormat.js | DocumentCommand.createSetDocumentFormat, DocumentRasterFormat |
boldItalics.js | DocumentCommand for text attribute changes |
See also
Example scripts
04a-batch-recolour.js
Download 04a-batch-recolour.js
'use strict';
// Example 04a — Batch recolour (compound command)
// Lesson 04: Command Pattern
//
// Recolours all selected shape nodes to a palette of colours.
// All changes are grouped into one undo step.
const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Selection } = require('/selections.js');
const { FillDescriptor } = require('/fills.js');
const { RGBA8 } = require('/colours.js');
const PALETTE = [
RGBA8(220, 60, 60, 255), // red
RGBA8( 60, 160, 220, 255), // blue
RGBA8( 80, 200, 120, 255), // green
RGBA8(240, 160, 40, 255), // amber
RGBA8(160, 80, 220, 255), // purple
RGBA8( 40, 190, 190, 255), // teal
];
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const shapes = doc.selection.nodes.filter(n => n.isShapeNode);
if (shapes.isEmpty) {
console.log('Select some shape nodes and run again.');
} else {
const compound = CompoundCommandBuilder.create();
let i = 0;
for (const node of shapes) {
const colour = PALETTE[i % PALETTE.length];
const fill = FillDescriptor.createSolid(colour);
const sel = Selection.create(doc, node);
const cmd = DocumentCommand.createSetBrushFill(sel, fill);
compound.addCommand(cmd);
i++;
}
doc.executeCommand(compound.createCommand());
console.log(`Recoloured ${i} shape(s) — one undo step.`);
}
}