The model and actions
The layout is a JSON model. Every change is an action, and every action can be intercepted, replaced or vetoed.
The model is the source of truth. What you render is a function of it, and it changes only
through actions. You never mutate nodes: tab.setName() exists on the node class for the
model's own use, but calling it bypasses everything described below.
JSON in, JSON out
import { type IJsonModel, Model } from "@fragiola/dockable";
const model = Model.fromJson(json); // IJsonModel -> Model
const saved: IJsonModel = model.toJson(); // Model -> IJsonModeltoJson() returns the whole layout (global attributes, rows, tabsets, tabs, popout windows) as
plain JSON you can store anywhere. Tabs without an id get a generated one, and toJson()
keeps it, so a round trip is stable. The format is FlexLayout's (IJsonModel); every attribute
is in the JSON model reference.
Model.fromJson(json, previousModel) takes an optional previous model: tabs with the same id
adopt its view state (their moveable element, scroll position), so a mounted layout can swap
to a new model without remounting tab content. Undo/redo is built on
this.
Dispatching actions
Actions has one static creator per change. Each returns an Action ({ type, data }):
import { Actions, DockLocation } from "@fragiola/dockable";
Actions.selectTab("t1");
Actions.deleteTab("t1");
Actions.renameTab("t1", "Report");
Actions.addTab({ type: "tab", name: "New", component: "card" }, "ts0", DockLocation.CENTER, -1);
Actions.moveNode("t1", "ts1", DockLocation.RIGHT, -1);
Actions.maximizeToggle("ts0");Dispatch them through the engine, which you get with useDockable() anywhere inside
Dockable.Root (tab content, your buttons, overlays):
import { Actions } from "@fragiola/dockable";
import { useDockable } from "@fragiola/dockable-react";
function CloseButton({ tabId }: { tabId: string }) {
const { engine } = useDockable();
return (
<button type="button" aria-label="Close" onClick={() => engine.doAction(Actions.deleteTab(tabId))}>
×
</button>
);
}engine.doAction, not model.doAction
engine.doAction(action) runs your onAction first, then applies the result with
model.doAction. Calling model.doAction directly applies the action without onAction,
so a veto or a rewrite you wrote there never sees it. The primitives always go through the
engine; do the same.
Actions.group([a, b, c]) applies several actions as one: one undo step and one change
notification. Every creator is listed in the Actions reference.
Intercepting: onAction
Dockable.Root's onAction sees every action the layout dispatches: yours, and the ones
the primitives dispatch (a click selecting a tab, a drop moving one, a splitter resizing a
row). "Return it (or a replacement) to apply it, undefined to veto":
import { type Action, Actions, DockLocation } from "@fragiola/dockable";
function onAction(action: Action): Action | undefined {
// veto: the "Home" tab cannot be closed
if (action.type === Actions.DELETE_TAB && action.data.node === "home") {
return undefined;
}
// rewrite: new tabs always open selected
if (action.type === Actions.ADD_TAB) {
return Actions.addTab(action.data.json, action.data.toNode, DockLocation.getByName(action.data.location), action.data.index, true);
}
return action;
}
<Dockable.Root model={model} onAction={onAction}>The action type constants are strings like "FlexLayout_DeleteTab" (kept from FlexLayout);
compare with Actions.DELETE_TAB, not the literal. action.data holds the creator's
arguments, under the names each creator uses (node, toNode, fromNode, tabNode, …; the
Actions reference lists them).
A drag is not special: a drop dispatches Actions.moveNode, so vetoing it cancels the drop. To
refuse a drop before it happens (so the indicator never shows it), use
model.setOnAllowDrop.
Reacting: onModelChange
onModelChange(model, action) is called after the model applied an action. It is the place to
persist the layout, sync it to a server or log it:
<Dockable.Root
model={model}
onModelChange={(model, action) => {
if (!action.isAdjusting()) {
localStorage.setItem("layout", JSON.stringify(model.toJson()));
}
}}
>While a splitter is dragged live, the engine applies adjustWeights actions marked as
adjusting (action.isAdjusting()) on every pointer move, and calls onModelChange for
each. Skip them (as above) or debounce: the gesture ends with a normal action.
For listeners outside React (or on a model not rendered yet), model.addChangeListener takes
{ onBeforeAction?, onAfterAction? } or a function. It fires for every action, including
direct model.doAction calls.
Reading the model
The model and its nodes are plain classes with getters, safe to call during render:
model.getRootRow(); // RowNode
model.getActiveTabset(); // TabSetNode | undefined
model.getMaximizedTabset(); // TabSetNode | undefined
model.getNodeById("t1"); // Node | undefined
model.getFirstTabSet(); // TabSetNode | undefined
model.visitNodes((node, level) => { /* every node, every layout */ });
tabset.getSelectedNode(); tabset.getTabNodes(); tabset.isActive(); tabset.isMaximized();
tab.getName(); tab.getComponent(); tab.getConfig(); tab.isSelected(); tab.getParent();The primitives re-render when the engine's revision changes, so reading the model inside the
layout is always current. Outside the root, subscribe to onModelChange.
One model, several layouts
A model holds the main layout (Model.MAIN_LAYOUT_ID) and one sub-layout per popout window.
Methods that depend on the layout take a layoutId (defaulting to the main one), and
useDockable().layoutId says which layout a component renders in.