Undo and redo
The package ships no undo/redo. It gives you the change events and layout.load to build yours; the examples' UndoManager is a starting point to copy.
Dockable ships no undo/redo: what counts as a step, what to ignore, and how steps across several layouts combine are your app's decisions. The package gives you what you need to build it:
- every change as an event:
model.subscribe(listener)receives oneCommandEventper committed command, with thecommand, itspayload, the statebeforeandafter, whether it wastransient(a step of a gesture, such as a splitter drag), the caller'smeta, and for abatchthecommandsit ran; - immutable states:
event.beforeis a snapshot that never changes, so keeping one costs nothing, andtoLayoutJson(state)turns it back into a document; - a restore that keeps content:
layout.loadreplaces the layout of the same model, and every tab whose id survives keeps its mounted content.
The examples' UndoManager (examples/_kit/undo.ts, in the code panel of the example below)
puts these together: it records the state before each step, and on undo or redo loads it back
with layout.load. It is small on purpose: new UndoManager(model, options), undo(), redo(),
and subscribe/getSnapshot for the UI, whose snapshot is { canUndo, canRedo, undoSteps, redoSteps }. Copy it, and change it to fit your app.
Wiring it to React
The manager is a small external store: subscribe and getSnapshot are bound, so they go
straight into useSyncExternalStore. Create it once, beside the model (useState): it subscribes
to the model in its constructor and listens for the model's whole life, so there is nothing to
dispose in an effect. The model never changes: undo and redo change its state, so
Dockable.Root keeps its engine and nothing remounts.
import { type CommandName, createModel, Dockable, type LayoutJson } from "@fragiola/dockable-react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { handleUndoKeys, UndoManager } from "./undo"; // the examples' _kit/undo.ts, copied into your app
type Types = { tabs: { card: undefined } };
// selecting is navigation, not an edit
const IGNORED: readonly CommandName[] = ["tabset.activate", "tab.select", "window.configure"];
export function UndoableLayout({ json }: { json: LayoutJson<Types> }) {
const [model] = useState(() => createModel<Types>(json));
const [undo] = useState(() => new UndoManager(model, { ignoreCommands: IGNORED }));
const { canUndo, canRedo } = useSyncExternalStore(undo.subscribe, undo.getSnapshot, undo.getSnapshot);
// Ctrl/Cmd+Z undoes, Shift+Ctrl/Cmd+Z and Ctrl/Cmd+Y redo; a text field keeps its own undo
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => handleUndoKeys(undo, event);
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [undo]);
return (
<>
<div role="toolbar" aria-label="History">
<button type="button" disabled={!canUndo} onClick={() => undo.undo()}>
Undo
</button>
<button type="button" disabled={!canRedo} onClick={() => undo.redo()}>
Redo
</button>
</div>
<Dockable.Root model={model} style={{ flex: 1, minHeight: 0 }}>
{/* Row, Panels, DropIndicator as usual */}
</Dockable.Root>
</>
);
}handleUndoKeys(undo, event) is the kit's shortcut handler: it takes a DOM KeyboardEvent or
React's, so it also fits an onKeyDown on the element that should own the shortcuts (the
dashboard example scopes them to itself that way).
What is recorded
The example manager listens to the model (model.subscribe), so it records every committed
command, whether it came from a primitive, a drag or your own model.run. These are its choices;
yours can differ:
- A gesture is one step. A live splitter drag runs many
transientrow.resizecommands and one final one; only the state before the first is recorded. - Some commands are not steps.
ignoreCommandsdefaults to["tabset.activate", "window.configure"]: clicking into another tabset, or moving a popout window, is not an undo step. A batch of only ignored commands is ignored too. - A
batchis one step. - A command that changed nothing (its
beforeis itsafter) is not a step. - Vetoed or refused commands never commit, so they are not recorded.
new UndoManager(model, {
maxBufferSize: 50, // default 100
ignoreCommands: ["tabset.activate", "tab.select", "window.configure"],
});Naming the steps
The snapshot lists the steps (undoSteps, oldest first, and redoSteps, the next one last). Each
step says what made it: command (the gesture's last one, or batch), and commands, a batch's
commands flattened. That is enough for a history list or a label such as "Undo close":
function describe(command: CommandName): string {
switch (command) {
case "tab.close":
return "Close tab";
case "tab.move":
case "tabset.move":
return "Move";
case "row.resize":
return "Resize";
default:
return command;
}
}
const next = snapshot.undoSteps.at(-1);
const label = next ? `Undo ${describe(next.command)}` : "Undo";Undo and your middleware
An undo or a redo is a layout.load run with meta: { undo: true }: the manager skips its own
event by that mark. Your middleware sees it like any other command: a rule on tab.move or
tab.close does not apply to it (it is a whole layout, not a move), and a middleware that vetoes
layout.load makes the undo a no-op (the step stays in the history). Since the model is the same,
middleware and listeners stay installed across undo and redo.
Loading another layout
Loading a saved layout into the same model (layout.load) is itself a step, which an undo takes
back. When it should not be, add a reset() to your copy that empties both buffers and takes the
model's current state as the one the next step goes back to. A manager listens to one model for
its lifetime: another model gets its own manager.
Content state across undo
Tabs that exist before and after an undo keep their mounted content: layout.load keeps the view
state (the moveable element) of every tab whose id survives. A tab that an undo removes is
unmounted; if a redo brings it back, it mounts fresh.
Layouts that exchange tabs
When tabs move between two layouts (two models), a step touches both models, so a history kept per model is not enough: keep one history for the group of layouts.