Undo and redo
The package ships no undo/redo. It gives you the events and the model 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.addChangeListener(listener)(oronModelChangeonDockable.Root) sees each action after the model applied it, andaction.isAdjusting()tells the steps of a gesture (a splitter drag) from its end; - the state as JSON:
model.toJson(); - a swap that keeps content:
Model.fromJson(json, previousModel)builds a model whose tabs adopt the previous model's mounted content, by id.
The examples' UndoManager (examples/_kit/undo.ts, in the code panel of the example below)
puts these together: it owns the current model, records a JSON snapshot before each change, and on
undo or redo replaces the model with Model.fromJson(snapshot, current). 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. Render Dockable.Root with the snapshot's model; a new
model identity makes the root create a new engine.
import { type IJsonModel, Model } from "@fragiola/dockable";
import { UndoManager } from "./undo"; // the examples' _kit/undo.ts, copied into your app
import { Dockable } from "@fragiola/dockable-react";
import { useEffect, useState, useSyncExternalStore } from "react";
export function UndoableLayout({ json }: { json: IJsonModel }) {
const [undo] = useState(() => new UndoManager(Model.fromJson(json)));
useEffect(() => () => undo.dispose(), [undo]);
const { model, canUndo, canRedo } = useSyncExternalStore(undo.subscribe, undo.getSnapshot);
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>
{model ? (
<Dockable.Root model={model} style={{ flex: 1, minHeight: 0 }}>
{/* Row, Panels, DropIndicator as usual */}
</Dockable.Root>
) : null}
</>
);
}For keyboard shortcuts, listen for Ctrl+Z / Ctrl+Shift+Z (or Meta on macOS) on the
document and call undo()/redo(), skipping events whose target is an editable field so text
inputs keep their own undo.
What is recorded
The example manager listens to the model (addChangeListener), so it records every action,
whether it came through engine.doAction or model.doAction. These are its choices; yours can
differ:
- A gesture is one step. A live splitter drag dispatches many adjusting
adjustWeightsactions and one final one; only the state before the first is recorded. - Some actions are not steps.
ignoreActionTypesdefaults to[Actions.SET_ACTIVE_TABSET]: clicking into another tabset is not an undo step. Actions.group([...])is one step.- Vetoed actions (your
onActionreturnedundefined) never reach the model, so they are not recorded.
new UndoManager(model, {
maxBufferSize: 50, // default 100
ignoreActionTypes: [Actions.SET_ACTIVE_TABSET, Actions.SELECT_TAB],
});Loading another layout
undo.setModel(model) replaces the model (after loading a saved layout, say) and clears the
history. setModel(model, false) keeps it. undo.reset() clears the history without replacing
the model.
Things that belong to the model instance
Undo and redo create new Model instances. Anything you set on a model object (not in its
JSON) must be set again on the new one, for example model.setOnAllowDrop(...):
const { model } = useSyncExternalStore(undo.subscribe, undo.getSnapshot);
useEffect(() => {
model?.setOnAllowDrop(allowDrop);
}, [model]);Content state across undo
Tabs that exist before and after an undo keep their mounted content: the new model's tabs adopt the old model's view state (their moveable element) by id. 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.