Dockable
Guides

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) (or onModelChange on Dockable.Root) sees each action after the model applied it, and action.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.

Intermediate exampleUndo and redoAn UndoManager you own (the kit's `_kit/undo.ts`): undo and redo buttons, Ctrl/Cmd+Z and Shift+Ctrl/Cmd+Z, and the list of steps. A splitter drag is one step, and the content keeps its state across undo.Open the live example

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 adjustWeights actions and one final one; only the state before the first is recorded.
  • Some actions are not steps. ignoreActionTypes defaults to [Actions.SET_ACTIVE_TABSET]: clicking into another tabset is not an undo step.
  • Actions.group([...]) is one step.
  • Vetoed actions (your onAction returned undefined) 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.

On this page