Commands, not snapshots
The editor is a PixiJS v8 scene: a design canvas, plus a mockup for every preview the merchant has set up. Undo cannot checkpoint that by copying it, so the history subsystem stores commands instead.
The interface is deliberately small:
export interface ICommand {
/** Execute the command */
execute(): void;
/** Undo the command */
undo(): void;
/** Optional label for debugging/UI display */
label?: string;
/** Called when the manager drops this command off the stack */
dispose?(): void;
}
Two methods and two optional hooks. Every editor action (move, resize, restyle, delete) is a class implementing that.
Grouping
One gesture is often several commands. HistoryManager.transaction(fn, label) collects them into a CompoundCommand, which runs its children in order on execute and in reverse on undo. One Ctrl+Z, one intent.
One stack is not enough
The design canvas and each mockup are separate editing surfaces, and an undo in one has no business reaching into another. So the manager keeps a stack pair per frame ('design' by default, plus a key per mockup) and setActiveFrame(key) decides which pair receives push, undo and redo. Code that never calls it behaves exactly like the old single-stack version.
Why dispose() exists
Each stack is capped, fifty commands by default. That cap is what makes the eviction hook necessary.
A delete command does not destroy the containers it removed: it holds them in a graveyard so redo can put them back. When the command is eventually trimmed off the end of the stack, dispose() is what releases them. Without the hook, a long editing session quietly retains every layer the merchant ever deleted.
The bug that shaped the API
Some property changes are debounced, because dragging a slider should not push sixty commands. But a debounced change that has not landed yet is invisible to the stack, so Ctrl+Z mid-drag would undo the previous action while the drag's own change stayed applied.
The fix is an event, not a timer. The manager emits BEFORE_CHANGE immediately before it runs undo or redo, and anything holding pending work flushes it synchronously first. Ctrl+Z reverts the latest intent because, by the time the traversal starts, the latest intent is on the stack.