Drag across windows and layouts
Drag tabs between popout windows, and between independent layouts with their own models that share a drag group.
A tab can leave the tabset it is in by more than a drop on another tabset of the same layout:
- between windows of one model: the main window and its popouts, in any direction;
- between layouts with different models on one page (two
Dockable.Roots), when they share aDockable.DragGroup.
In both cases the tab's content keeps its state: its moveable element is re-parented, never cloned, and its React tree is never remounted.
Between windows
Nothing to switch on. The windows of a page share one drag state, and each window's engine
listens to its own document, so a drag that starts in the main window drops in a popout, and the
other way round. The drop is an ordinary tab.move (or tabset.move) run on the model, through
its middleware.
Two things make it look right:
-
An indicator in each window. The outline is drawn by the engine of the window under the pointer. Put a
Dockable.DropIndicatorinDockable.Popoutas well as in the main layout:<Dockable.Popout<Types>> {() => ( <> <Dockable.Row<Types>>{renderNode}</Dockable.Row> <Dockable.DropIndicator className="drop-indicator" /> </> )} </Dockable.Popout> -
The theme in each window. Stylesheets are copied into every popout; attributes of
<html>and<body>are not, unless you ask.popoutMirrorRootonRootcopies them and keeps them in sync (a theme switch follows into open windows):<Dockable.Root model={model} popoutMirrorRoot> {/* every attribute but style and id */} <Dockable.Root model={model} popoutMirrorRoot={["class", "data-theme"]}> {/* only these */}
Buttons that open and close windows
Dockable.PopoutTrigger goes inside a TabSet. It pops the selected tab out (or, with
target="tabset", the whole tabset), and in a window it docks back into the main layout. It
renders nothing when neither is possible (the model is asked with model.can), and its
data-mode ("popout" or "dock") tells your icon which way it goes:
<Dockable.PopoutTrigger aria-label="Pop out" className="group">
<ExternalLinkIcon aria-hidden className="group-data-[mode=dock]:hidden" />
<DockIcon aria-hidden className="hidden group-data-[mode=dock]:block" />
</Dockable.PopoutTrigger>
<Dockable.PopoutTrigger target="tabset" aria-label="Move this group to a window" className="data-[mode=dock]:hidden">
<AppWindowIcon aria-hidden />
</Dockable.PopoutTrigger>From code, the same operations are commands: model.run("tab.popout", { tabId }) and
model.run("tabset.popout", { tabsetId }) open a window, model.run("window.close", { windowId })
docks a whole window back. The engine wraps them by node id, for a tab or a tabset:
engine.can("popout", { nodeId }) answers with a boolean, engine.run("popout", { nodeId }) and
engine.run("dock-back", { nodeId }) return the command's result, and
model.is("node-in-window", { nodeId }) says whether the node lives in a window. Popout stays opt-in per tab:
poppable: true on a tab, or for every tab in the layout's defaults:
const json: LayoutJson<Types> = {
version: 1,
defaults: { tab: { poppable: true } },
root: { type: "row", children: [/* … */] },
};Between layouts
Two roots with different models exchange tabs when a Dockable.DragGroup wraps them:
<Dockable.DragGroup onTransfer={(transfer) => console.log(transfer.tabId, "moved")}>
<Dockable.Root model={workspace}>{/* … */}</Dockable.Root>
<Dockable.Root model={scratch}>{/* … */}</Dockable.Root>
</Dockable.DragGroup>Layouts outside a shared group refuse each other's drags. The roots can be anywhere below the group, and a layout's popout windows take part too.
A transferred tab keeps its component and data, so the models of a group should share a
registry (the same Types), or at least the components that travel between them.
What a transfer does
A drop into another model is a transfer: a tab.add in the target model, then a tab.close
in the source model, each through its own model's middleware.
-
Both sides are asked first. The group asks the target with
model.can("tab.add", …)and the source withmodel.can("tab.close", …). If either refuses (a rule of the layout, a middleware veto), nothing changes. Only then do the two commands run. -
Both commands say they are a transfer. Their
meta.transferholds{ tabId, from, to }(the tab's id in the source, and the two models), so a middleware or a listener can tell a transfer from an ordinary add or close:import { type Middleware, type TransferMeta, veto } from "@fragiola/dockable-react"; const MAX_TABS = 5; const scratchLimit: Middleware<Types> = (ctx, next) => { if (ctx.command === "tab.add" && scratch.get("all-tabs").length >= MAX_TABS) { return veto("Scratch takes no more tabs."); } return next(); }; scratch.use(scratchLimit); scratch.subscribe((event) => { const transfer = (event.meta as Partial<TransferMeta> | undefined)?.transfer; if (transfer && event.command === "tab.add") { console.log(`${transfer.tabId} arrived from another layout`); } });While the drag hovers, the target is asked
model.can("tab.add", …)with the samemeta.transfer, so a rule on it also hides the outline over a target it refuses. -
The target model's rules apply. Hit testing runs in the target layout, and every candidate drop is the
tab.addit would run, asked withmodel.can: the target'sdroppableandsplittableflags and its middleware decide where the tab may land. -
The content moves with it. The new tab adopts the old one's moveable element, and the group renders every tab's content in one place keyed by tab id, so components under the tab keep their state.
-
One event.
onTransferonDockable.DragGroup(orgroup.onTransfer(listener)) receives aTransfer:tab(the tab's id in the target),previousId(its id in the source),init(its fields as it left), andfromandto, each{ model, layoutId, tabsetId, index }.from.modelandto.modelare the models themselves: compare them with yours,transfer.from.model === workspace.
A transfer can also run from code, with the same checks and the same event. useDragGroup()
returns the core group:
const group = useDragGroup();
const id = group.transfer({
tabId: "report",
from: workspace,
to: scratch,
target: "scratch-main", // a tabset, row, border or layout of `to`
location: "center", // the default
index: -1, // append (the default)
});It returns the tab's id in the target model, or undefined when a side refused or the move was
impossible (an unknown tab or target, or a model not in the group).
Undo is yours
Dockable ships no undo/redo: what counts as a step, and what undoing a move between layouts means, is your app's decision. The transfer event carries what an undo needs. One design keeps one history for the group, in which undoing a transfer moves the tab back into its old tabset in the first layout, so it disappears from the second, with its content kept:
// a history of the group's transfers, abridged
connect() {
return this.group.onTransfer((transfer) => {
if (this.replaying) return; // our own undo or redo
const from = this.modelOf(transfer.from.model); // our Model<T>, found with ===
const to = this.modelOf(transfer.to.model);
if (!from || !to) return;
this.undoStack.push({
from: { model: from, tabId: transfer.previousId, tabsetId: transfer.from.tabsetId, index: transfer.from.index },
to: { model: to, tabId: transfer.tabId, tabsetId: transfer.to.tabsetId, index: transfer.to.index },
});
this.redoStack = [];
});
}
undo() {
const step = this.undoStack.pop();
const moved = step && this.move(step.to, step.from);
if (step && moved) this.redoStack.push({ ...step, from: { ...step.from, tabId: moved } });
}
private move(from: StepEnd, to: StepEnd): string | undefined {
const target = targetIn(to); // its old tabset, or the first one if that is gone
if (!target) return undefined;
this.replaying = true;
try {
const index = target === to.tabsetId ? to.index : -1;
return this.group.transfer({ tabId: from.tabId, from: from.model, to: to.model, target, location: "center", index });
} finally {
this.replaying = false;
}
}Copy it and adapt it: a history of each layout's own changes is recorded the same way, from each
model's subscribe (see Undo and redo).
Limits
- Only tabs cross models. A tabset drags within its own model (windows included), never into another model.
- One React tree. The roots that exchange tabs must be under one
Dockable.DragGroup, in one page (the page's popout windows count). - Content renders under the group. To survive a move between roots, every tab's content renders
in one place directly under
Dockable.DragGroup, with the layout's own contexts passed along. Providers, error boundaries and Suspense boundaries placed between the group and a panel do not reach the content: put the ones the content needs aboveDockable.DragGroup, or inside the content itself. - Ids are per model. A tab keeps its id when the target model has no node with that id;
otherwise it gets a generated one. Read the new id from
transfer.tabId, and the old one fromtransfer.previousId. - HTML5 drag and drop across windows needs a desktop browser that supports popouts; where popouts are unsupported, windows are docked back and there is nothing to drag between.