Documentation
Guides

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 one CommandEvent per committed command, with the command, its payload, the state before and after, whether it was transient (a step of a gesture, such as a splitter drag), the caller's meta, and for a batch the commands it ran;
  • immutable states: event.before is a snapshot that never changes, so keeping one costs nothing, and toLayoutJson(state) turns it back into a document;
  • a restore that keeps content: layout.load replaces 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.

Undo and redo
Gallery

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 transient row.resize commands and one final one; only the state before the first is recorded.
  • Some commands are not steps. ignoreCommands defaults 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 batch is one step.
  • A command that changed nothing (its before is its after) 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.