History & transactions
Undo, redo, and grouping edits into one step.
Every successful command is one undo step, with no setup.
project.undo(); // true if there was something to undo
project.redo(); // true if there was something to redo
project.canUndo();
project.canRedo();
project.clearHistory();| Fact | Detail |
|---|---|
| What's recorded | Document changes only. setPlayhead and setSelection never create undo steps |
| Exactness | Each step stores the inverse patches of its change, so undo restores the previous document exactly |
| Limit | 500 steps by default; createProject(init, { historyLimit }) changes it. The oldest steps drop first |
| New edits | Dispatching after an undo clears the redo stack |
| Loading | createProject(saved) starts with empty history |
Transactions
transaction(fn, label?) turns every command dispatched inside fn into one undo step.
project.transaction(() => {
project.dispatch({ type: "clip/move", payload: { clipId: "intro", startUs: 2_000_000 } });
project.dispatch({ type: "track/rename", payload: { trackId: "main", name: "Main" } });
}, "Move and rename");
project.undo(); // reverts bothIt's all or nothing. If anything inside fn throws, every change made so far in the transaction is rolled back, and the error is rethrown:
try {
project.transaction(() => {
project.dispatch({ type: "clip/move", payload: { clipId: "intro", startUs: 3_000_000 } });
project.dispatch({ type: "clip/move", payload: { clipId: "missing", startUs: 0 } }); // throws
});
} catch {
// "intro" is back where it was; no undo step was added
}Use transactions for any user action that takes several commands: a ripple delete, pasting several clips, applying a template.
applyCommands(project, commands) does the same for an array of commands and returns a result instead of throwing, including the index of the command that failed. It's built for AI agents: Tools from the command catalog.
Gestures
Coming in the next release
project.beginGesture ships in the next release of @miraiclip/core.
A transaction runs inside one function. A gesture stays open across many events, so a slider drag, a keyframe scrub or an async AI edit becomes one undo step however many commands it dispatches.
const gesture = project.beginGesture("Adjust volume");
slider.addEventListener("input", () => {
project.dispatch({ type: "clip/set-property", payload: { clipId: "music", volume: Number(slider.value) } });
});
slider.addEventListener("change", () => gesture.end()); // one undo step
cancelButton.addEventListener("click", () => gesture.cancel()); // revert everything since beginGesture| Member | Meaning |
|---|---|
beginGesture(label?) | Start a gesture. A nested call joins the open one |
handle.end() | Commit the gesture's edits as one step. No step if nothing changed |
handle.cancel() | Revert every edit the gesture made |
inGesture() | true while a gesture is open |
end() and cancel() are safe to call twice. Calling undo() or redo() while a gesture is open ends it first.
beginGesture(project, label) in @miraiclip/editor is a different helper for live previews that replace themselves on every update. See Headless editor logic.
Labels
The label appears on the history event, so you can show "Undo Move and rename" in a menu:
let lastLabel: string | undefined;
project.events.on("history", ({ kind, label }) => {
if (kind === "commit") lastLabel = label;
});kind | When |
|---|---|
commit | A command or transaction finished |
undo | undo() ran; label is the step undone |
redo | redo() ran |
Single dispatches have no label. To label one, wrap it in a transaction.
Undo/redo buttons
canUndo() and canRedo() are methods, not state. Re-read them when history changes:
const undoButton = document.querySelector<HTMLButtonElement>("#undo")!;
const redoButton = document.querySelector<HTMLButtonElement>("#redo")!;
function refresh() {
undoButton.disabled = !project.canUndo();
redoButton.disabled = !project.canRedo();
}
project.events.on("history", refresh);
refresh();