Drag across windows and layouts
Tabs dragged between popout windows, and between independent layouts 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
Intermediate exampleDrag between windowsPop a tab (or a whole tabset) out into a window, then drag tabs between the window and the main layout, both ways. The content keeps its state, and the window shows its own drop outline.Open the live exampleNothing 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 Actions.moveNode through the main root's onAction.
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> {() => ( <> <Dockable.Row>{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, 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 engine has the same operations: engine.canPopout(node), engine.popout(node),
engine.dockBack(node) and engine.isInWindow(node), for a tab or a tabset. Popout stays opt-in
per tab (tabEnablePopout: true in the global attributes).
Between layouts
Advanced exampleTwo layoutsTwo independent layouts (two models) exchange tabs by drag and drop, and the content keeps its state. Undo and redo are the app's: a history built on the transfer events, where undoing a move brings the tab back to where it came from.Open the live exampleTwo roots with different models exchange tabs when a Dockable.DragGroup wraps them:
<Dockable.DragGroup onTransfer={(transfer) => console.log(transfer.tab.getName(), "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, as before. The roots can be anywhere below the group, and a layout's popout windows take part too.
What a transfer does
A drop into another model is a transfer: the tab is added to the target model (with the same id and JSON) and deleted from the source model.
-
Both sides are asked first. The target root's
onActionreceives anaddTab, then the source root's receives adeleteTab. If either returnsundefined(or replaces the action with another kind), nothing changes. So the target'sonActionmay see anaddTabthat the source then refuses: record history fromonModelChangeoronTransfer(what happened), not fromonAction(what was asked). -
Both actions say they are a transfer. Their
userData.transferholds{ tabId, from, to }(the two models), so anonActionor anonModelChangecan tell a transfer from an ordinary add or close:import type { ITransferUserData } from "@fragiola/dockable"; <Dockable.Root model={scratch} onAction={(action) => { const transfer = (action.userData as Partial<ITransferUserData> | undefined)?.transfer; if (transfer && action.type === Actions.ADD_TAB && isFull(scratch)) { return undefined; // Scratch takes no more tabs } return action; }} > -
The target model's rules apply. Hit testing runs in the target layout, so its
enableDrop/enableDivideattributes and itsonAllowDropdecide where the tab may land. -
The content moves with it. The new tab adopts the old one's view state, 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{ tab, json, from, to }, wherefromandtoare{ model, layoutId, tabsetId, index }.
A transfer can also run from code, with the same checks and the same event:
useDragGroup() returns the core group, and
group.transfer(tabId, fromModel, toModel, toNodeId, DockLocation.CENTER, index) returns the new
tab, or undefined when a side vetoed or the move was impossible (an unknown tab or target, an
id already used in the target model).
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. The
two-layouts example 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:
// examples/two-layouts/history.ts, abridged
connect() {
return this.group.onTransfer((transfer) => {
if (this.replaying) return; // our own undo or redo
this.undoStack.push({ tabId: transfer.tab.getId(), from: transfer.from, to: transfer.to });
this.redoStack = [];
});
}
undo() {
const step = this.undoStack.pop();
if (step && this.move(step, step.to, step.from)) this.redoStack.push(step);
}
private move(step: Step, from: ITransferEnd, to: ITransferEnd) {
const target = targetIn(to.model, to); // its old tabset, or the first one if that is gone
if (!target) return false;
this.replaying = true;
try {
const index = target === to.tabsetId ? to.index : -1;
return this.group.transfer(step.tabId, from.model, to.model, target, DockLocation.CENTER, index) !== undefined;
} finally {
this.replaying = false;
}
}Copy it and adapt it: a history of each layout's own changes is recorded the same way, from each
root's onModelChange (see Undo and redo).
Limits
- Only tabs cross models. A tabset, a tab group or a float's layout 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 whose id is already used in the target model is refused. Models built from JSON without ids get unique generated ids, so this only matters for ids you set yourself.
- 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.