@mochabug/adapt-web
Version:
Browser client library for Adapt automation platform
596 lines (595 loc) • 23.4 kB
TypeScript
/**
* Panel management system with recursive split layout.
*
* Key design: "iframe portal" architecture — iframes live in a fixed
* portal layer and NEVER move after initial placement. Each panel's
* `wrapper` stays inside the group content as an empty placeholder;
* the panel's `element` (containing the iframe) is placed in a portal
* entry positioned via `position: fixed` over the wrapper using a
* `requestAnimationFrame` sync loop.
*
* This prevents iframe reloads during ALL panel operations (split,
* float, dock, move, minimize, mode switch) because only empty
* placeholders are moved by layout operations — the iframes themselves
* are never reparented after creation.
*
* Tab dragging uses pointer events (NOT HTML5 DnD) so there is no
* browser snap-back animation and we get full control over the ghost
* element and drop indicators.
*
* The SplitLayout uses a recursive binary tree of SplitNode / LeafNode.
* When splitting a leaf, the new split container is inserted into DOM
* FIRST (via insertBefore), then the existing leaf is moved INTO it
* (via appendChild — which moves, never removes). Since panel wrappers
* are empty placeholders, these moves are always safe.
*/
export interface PanelOptions {
id: string;
element: HTMLElement;
title?: string;
groupId?: string;
}
export declare class Panel {
readonly id: string;
readonly element: HTMLElement;
title: string;
group: PanelGroup | null;
readonly wrapper: HTMLElement;
constructor(id: string, element: HTMLElement, title?: string);
}
export type DockPosition = "top" | "bottom" | "left" | "right";
export interface GroupOptions {
id?: string;
floating?: {
x: number;
y: number;
width: number;
height: number;
};
direction?: DockPosition;
initialSize?: number | undefined;
}
/**
* Advance the group ID counter past any restored group IDs so that
* newly created groups (e.g. via DnD) never collide with restored ones.
*/
export declare function syncGroupIdCounter(existingIds: string[]): void;
export declare class PanelGroup {
readonly id: string;
readonly element: HTMLElement;
readonly headerElement: HTMLElement;
readonly headerPrefix: HTMLElement;
readonly tabsElement: HTMLElement;
readonly headerVoid: HTMLElement;
readonly headerActions: HTMLElement;
readonly contentElement: HTMLElement;
private _panels;
private _activePanel;
private _headerHidden;
private _locked;
private _onTabClose;
/** Pointer-based tab drag start (replaces HTML5 dragstart) */
private _onTabPointerDown;
private _onTabPopOut;
private _location;
private _onLocationChangedCb;
constructor(id?: string);
get panels(): readonly Panel[];
get activePanel(): Panel | null;
get size(): number;
get locked(): string | false;
set locked(value: string | false);
setHeaderHidden(hidden: boolean): void;
get headerHidden(): boolean;
setHeaderActions(element: HTMLElement): void;
setHeaderPrefix(element: HTMLElement): void;
onTabClose(cb: (panelId: string) => void): void;
onTabPointerDown(cb: (panelId: string, event: PointerEvent) => void): void;
get location(): "grid" | "floating";
set location(value: "grid" | "floating");
onLocationChanged(cb: (loc: "grid" | "floating") => void): void;
/** Force-fire the location changed callback without changing the value. */
fireLocationChanged(): void;
private _isInFloatingSplit;
private _onSplitStateChangedCb;
get isInFloatingSplit(): boolean;
set isInFloatingSplit(value: boolean);
onSplitStateChanged(cb: (isInSplit: boolean) => void): void;
onTabPopOut(cb: (panelId: string) => void): void;
/** Drag constraint: "any" allows splits, "merge-only" blocks new splits in the main layout. */
private _dragConstraint;
get dragConstraint(): "any" | "merge-only";
set dragConstraint(v: "any" | "merge-only");
/** When true, tabs from this group cannot be docked (moved from floating to grid). */
private _dockingBlocked;
get dockingBlocked(): boolean;
set dockingBlocked(v: boolean);
/** When true, tabs from this group cannot create splits inside floating overlays. */
private _dialogDockingBlocked;
get dialogDockingBlocked(): boolean;
set dialogDockingBlocked(v: boolean);
private _onPanelCountChangedCb;
private _onActivePanelChangedCb;
onPanelCountChanged(cb: (count: number) => void): void;
onActivePanelChanged(cb: (panelId: string) => void): void;
addPanel(panel: Panel): void;
/**
* Remove a panel and its wrapper from DOM. Used when closing/destroying
* a panel. For moves between groups, use detachPanel instead.
*/
removePanel(panelId: string): Panel | null;
/**
* Detach a panel from this group WITHOUT removing its wrapper from DOM.
* The caller is expected to move the wrapper to a new parent via
* addPanel, whose appendChild call will MOVE (not unmount) the iframe.
*/
detachPanel(panelId: string): Panel | null;
activatePanel(panelId: string): void;
reorderPanel(panelId: string, dropX: number): void;
private createTab;
dispose(): void;
}
export interface FloatingOptions {
x: number;
y: number;
width: number;
height: number;
}
export declare class FloatingOverlay {
readonly element: HTMLElement;
/** Current owner group. Updated when ownership transfers after the original owner is removed from an internal split. */
group: PanelGroup;
/** Internal split layout for splitting within this floating overlay. */
internalLayout: SplitLayout | null;
private toolbarElement;
private bounds;
private savedBounds;
private _isMaximized;
private _onDragEnd;
private _onDragMove;
private _onBoundsChanged;
constructor(group: PanelGroup, opts: FloatingOptions, parentElement?: HTMLElement, insertBefore?: HTMLElement | null);
get isMaximized(): boolean;
set onDragEnd(cb: (centerX: number, centerY: number) => void);
set onDragMove(cb: (centerX: number, centerY: number) => void);
set onBoundsChanged(cb: () => void);
bringToFront(): void;
maximize(): void;
restore(): void;
hide(): void;
show(): void;
dispose(): void;
getBounds(): FloatingOptions;
/** Update specific bound dimensions without a full resize. Clamps to viewport with padding for drag handles. */
setBounds(partial: Partial<{
width: number;
height: number;
}>): void;
/** Replace all bounds (x, y, width, height), clamping to current viewport with padding. */
setFullBounds(bounds: FloatingOptions): void;
private applyBounds;
/**
* Wire a group's headerVoid as a drag handle for this overlay.
* Used after ownership transfer when the original group is removed.
*/
setDragGroup(newGroup: PanelGroup): void;
/**
* Allow dragging from the headerActions area (toolbar buttons region)
* without interfering with actual button clicks.
*/
private wireDragOnActions;
/**
* Place a toolbar actions element into the correct group's header.
* Appends to headerActions (doesn't replace) so it coexists with
* ForkGroupActions which occupies the same container.
*/
setToolbar(el: HTMLElement): void;
/**
* Remove the toolbar element from whichever group's header it's in.
*/
removeToolbar(): void;
/**
* Move the toolbar to the correct group header after a split change.
* Uses appendChild which MOVES the element if already in DOM.
*/
repositionToolbar(): void;
/**
* Find the group whose header should host the toolbar buttons.
* For a single group: the owner. For splits: the topmost-rightmost
* group, so the buttons appear at the far right of the top row.
*/
private getToolbarHost;
/**
* Recursively find the topmost-rightmost leaf in a layout tree.
* Horizontal split → go right (second). Vertical split → go top (first).
*/
private static topRightGroup;
/** Whether a toolbar is already attached. */
get hasToolbar(): boolean;
/** Return all groups in this overlay (internal split or just the owner). */
getAllGroups(): PanelGroup[];
/** Update data-total-tab-count on the overlay element. */
updateTotalTabCount(): void;
private onDragStart;
private onResizeStart;
}
type SplitDirection = "horizontal" | "vertical";
interface SplitNode {
kind: "split";
direction: SplitDirection;
first: LayoutNode;
second: LayoutNode;
element: HTMLElement;
separator: HTMLElement;
}
interface LeafNode {
kind: "leaf";
group: PanelGroup;
element: HTMLElement;
}
type LayoutNode = SplitNode | LeafNode;
export interface SerializedLeafNode {
kind: "leaf";
groupId: string;
flex: string;
}
export interface SerializedSplitNode {
kind: "split";
direction: SplitDirection;
first: SerializedLayoutNode;
second: SerializedLayoutNode;
flex: string;
}
export type SerializedLayoutNode = SerializedLeafNode | SerializedSplitNode;
export interface SerializedFloatingGroup {
groupId: string;
bounds: FloatingOptions;
internalLayout: SerializedLayoutNode | null;
isMaximized?: boolean;
}
export interface SerializedPanelLayout {
mainLayout: SerializedLayoutNode | null;
floatingGroups: SerializedFloatingGroup[];
panelLocations: Record<string, string>;
}
export declare class SplitLayout {
readonly element: HTMLElement;
private root;
onSplitResized: (() => void) | null;
constructor(container: HTMLElement);
/** Get the root layout node. */
getRoot(): LayoutNode | null;
/**
* Set the initial main group as the root leaf.
*/
setMainGroup(group: PanelGroup): void;
/**
* Split the leaf containing targetGroup, placing newGroup beside it.
* Returns true if the split was performed, false if blocked (e.g. max splits).
*
* CRITICAL: DOM operations are ordered so iframes are NEVER detached:
* 1. Create the split container element
* 2. Insert split container WHERE the leaf is (insertBefore)
* 3. Move the existing leaf INTO the split container (appendChild moves it)
* 4. Create new leaf and add it to split container
*/
splitGroup(targetGroup: PanelGroup, newGroup: PanelGroup, direction: DockPosition, newGroupSize?: number): boolean;
/**
* Remove a group's leaf and collapse its parent split.
*
* CRITICAL: When collapsing, the sibling is moved BEFORE the parent
* split container is removed, so iframes are never detached:
* 1. insertBefore(sibling.element, parent.element) — sibling stays in document
* 2. parent.element.remove() — only removes the empty parent shell
*/
removeGroup(group: PanelGroup): void;
/**
* Find the leaf node containing a given group.
*/
findGroupNode(group: PanelGroup): LeafNode | null;
/**
* Determine the dock position of a group relative to its sibling.
* Returns the sibling group and the direction needed to re-create
* this split with splitGroup(siblingGroup, group, direction).
*/
getGroupPosition(group: PanelGroup): {
siblingGroup: PanelGroup;
direction: DockPosition;
} | null;
private firstGroupIn;
/**
* Collapse a group's leaf to zero size, hiding it from view while
* keeping it in the tree so splitGroup() calls still find it.
*/
collapseLeaf(group: PanelGroup): void;
/**
* Expand a previously collapsed leaf back to normal size.
*/
expandLeaf(group: PanelGroup): void;
/**
* Maximize a group's leaf so it fills the entire layout.
* Walks from the leaf up to the root, hiding the sibling and separator
* at each split level so the leaf visually occupies the full area.
*/
maximizeLeaf(group: PanelGroup): void;
/**
* Restore a previously maximized leaf back to normal layout.
*/
restoreMaximizedLeaf(group: PanelGroup): void;
/**
* Restore all maximize-related saved state in a subtree.
* Used before removeGroup to ensure no display:none or flex overrides leak.
*/
private restoreMaximizedSubtree;
/**
* Iterate all leaf nodes (groups) in the layout.
*/
allGroups(): PanelGroup[];
/**
* Serialize the layout tree to JSON for persistence.
*/
toJSON(): SerializedLayoutNode | null;
/**
* Restore flex values from a serialized layout tree.
*
* Uses a parallel tree walk when structures match (same kind at each level),
* which correctly restores flex on split containers as well as leaves.
* Falls back to a flat groupId→flex map when trees diverge structurally.
*/
restoreFlexFromSerialized(serialized: SerializedLayoutNode | null): void;
private restoreFlexParallel;
/**
* Temporarily enable flex transition on all layout nodes so that
* dock/undock/maximize causes a smooth resize animation.
*/
animateTransition(): void;
/**
* Replace the entire layout tree with one built from a serialized tree.
* Correctly handles ANY tree topology (nested horizontal/vertical splits
* in any order) which the incremental splitGroup approach cannot.
*
* IFRAME SAFETY: The new tree structure is appended to the DOM FIRST,
* then group.elements are MOVED (via appendChild, which never detaches
* from the document) from their old positions into the new leaves.
* Finally the old tree is removed (now empty).
*/
rebuildFromSerialized(serialized: SerializedLayoutNode, groupLookup: (groupId: string) => PanelGroup | undefined): void;
private buildNodeFromSerialized;
/** Move each group's element into its corresponding leaf in the tree. */
private moveGroupsIntoTree;
dispose(): void;
private createLeaf;
private dockToSplitDirection;
private findGroupNodeIn;
private collectGroups;
private findParentSplit;
private findParentSplitIn;
/**
* Replace oldNode with newNode in the tree structure.
*/
private replaceNodeInTree;
private collectFlexValues;
private applyFlexValues;
/** Check that a flex string is a valid CSS flex value. */
private isValidFlex;
private serializeNode;
private onSeparatorDrag;
/**
* Lock descendant splits in the same direction so their inner
* separators don't move when the ancestor container resizes.
* First child gets pixel-locked, second child absorbs changes.
*/
private lockDescendantSizes;
/**
* Restore descendant splits to proportional flex based on their
* current rendered sizes (no visual jump).
*/
private unlockDescendantSizes;
}
export interface PanelManagerOptions {
container: HTMLElement;
onPanelRemoved?: (panel: Panel) => void;
/** Called when DnD creates a new floating group. */
onGroupCreatedByDrag?: (group: PanelGroup) => void;
/** Called whenever the layout changes (panel moved, floating drag/resize, split resize). */
onLayoutChanged?: () => void;
/** Edge size in px for split zone detection (default 100) */
splitEdgeSize?: number;
}
export declare class PanelManager {
private container;
private layout;
private groups;
private panels;
private floatingOverlays;
private mainGroupId;
private onPanelRemovedCbs;
private onGroupCreatedByDragCb;
private onLayoutChangedCb;
private disposed;
private dragState;
private dropOverlay;
private splitEdgeSize;
private portalLayer;
private portals;
private portalRaf;
private mainGroupCollapsed;
private _beforeTabClose;
private zBase;
constructor(opts: PanelManagerOptions);
private getZBase;
setBeforeTabClose(cb: ((panelId: string) => boolean | Promise<boolean>) | null): void;
addPanel(opts: PanelOptions): Panel;
removePanel(panelId: string): void;
getPanel(panelId: string): Panel | undefined;
addGroup(opts?: GroupOptions): PanelGroup;
removeGroup(groupId: string): void;
getGroup(groupId: string): PanelGroup | undefined;
getGroupIds(): string[];
getMainGroup(): PanelGroup | undefined;
setMainGroup(group: PanelGroup): void;
collapseMainGroup(): void;
expandMainGroup(): void;
isMainGroupCollapsed(): boolean;
getDockedGroups(): PanelGroup[];
collapseDockedGroup(group: PanelGroup): void;
expandDockedGroup(group: PanelGroup): void;
maximizeDockedGroup(group: PanelGroup): void;
restoreMaximizedDockedGroup(group: PanelGroup): void;
/** Restore split flex values from a serialized layout tree. */
restoreLayoutFlex(serialized: SerializedLayoutNode | null): void;
/**
* Rebuild the entire docked layout from a serialized tree.
* All groups referenced in the tree must already exist in the groups map.
* Correctly reproduces ANY tree topology including mixed h/v splits.
* Flex values from the serialized tree are applied during construction.
*/
rebuildDockedLayout(serialized: SerializedLayoutNode): void;
/**
* Rebuild a floating overlay's internal split layout from serialized data.
* All groups referenced must already exist in the groups map.
*/
rebuildFloatingInternalLayout(ownerGroupId: string, serialized: SerializedLayoutNode): void;
addFloatingGroup(group: PanelGroup, opts: FloatingOptions): FloatingOverlay;
/**
* Move a floating group into a split position without unmounting iframes.
* splitGroup's insertBefore+appendChild MOVES group.element out of the
* overlay first, then the empty overlay shell is cleaned up.
*
* Handles all cases: direct overlay owner, owner with internal splits,
* and groups inside another overlay's internal split.
*/
dockGroup(groupId: string, position: DockPosition, initialSize?: number): void;
getFloatingOverlay(groupId: string): FloatingOverlay | undefined;
/**
* Dock an entire floating overlay to the grid, preserving internal splits.
* All groups in the overlay are moved into the main split layout.
*/
dockOverlay(groupId: string): void;
/**
* Find the floating overlay containing a group — either as the owner
* or inside the overlay's internal split layout.
*/
findOverlayContaining(groupId: string): FloatingOverlay | undefined;
/**
* Move a panel from one group to another WITHOUT unmounting.
* Uses detachPanel (keeps wrapper in DOM) + addPanel (appendChild moves it).
*/
movePanel(panelId: string, targetGroupId: string): void;
/**
* Serialize the full layout state for persistence.
*/
serializeLayout(): SerializedPanelLayout;
/**
* Get all floating overlay entries (for external iteration).
*/
getFloatingOverlays(): ReadonlyMap<string, FloatingOverlay>;
/**
* Remove all empty non-main groups from the layout.
* Safety net that catches edge cases where movePanel/removePanel
* cleanup didn't fire (e.g. complex split + drag sequences).
*/
cleanupEmptyGroups(): void;
onDidRemovePanel(cb: (panel: Panel) => void): {
dispose(): void;
};
dispose(): void;
private wireGroupCallbacks;
private onTabPointerDown;
private cancelTabDrag;
private createDragGhost;
private hitTestDropZone;
/**
* Determine which content drop zone the cursor is in.
*
* Edge detection on ALL groups (not just locked):
* - Within splitEdgeSize px of edge -> content-split (split this group)
* - Center -> content-center (merge into this group)
* - Locked groups -> entire area is content-split (closest edge)
*/
private classifyContentZone;
/**
* Apply drag constraints to a drop zone, converting or nullifying zones
* that violate the source group's constraints.
*/
private applyDragConstraints;
private updateDropOverlay;
private showSplitIndicator;
private positionDropOverlay;
private detachPanelToFloating;
/**
* Dock a dragged tab into a split position on the target group.
* Creates a new group and splits the target group.
*
* For floating groups: creates an internal SplitLayout inside the
* floating overlay so the split happens WITHIN the overlay.
*/
private dockTabFromDrag;
/**
* Find (or create) the SplitLayout that manages a given group.
*
* - Groups in the main layout → this.layout
* - Groups inside a floating overlay → overlay.internalLayout (created on demand)
*/
private getLayoutForGroup;
/**
* Break a group out of a floating overlay's internal split into its own
* floating overlay. The group.element is MOVED (not unmounted) by creating
* the new overlay BEFORE removing the empty leaf from the old one.
*/
escapeGroupFromSplit(groupId: string): void;
/**
* Update isInFloatingSplit on all groups inside an overlay's internal layout.
*/
private updateFloatingSplitState;
/**
* Clean up a floating overlay's internal split after a group has been
* removed from it. Collapses the internal layout to a single group
* when only one remains, or updates split state if multiple remain.
*
* Callers are responsible for ownership transfer in the floatingOverlays
* map BEFORE calling this method.
*
* MUST be called AFTER overlay.internalLayout.removeGroup(group) and
* AFTER the group's element has already been moved out of the overlay
* (e.g. via FloatingOverlay constructor or splitGroup's appendChild).
*
* @param overlay - The overlay whose internal layout lost a group
* @returns The remaining groups in the overlay (empty if none left)
*/
private cleanupOrphanedFloatingLeaf;
/**
* Remove a group from the main split layout, collapsing its space.
* The group stays alive (not disposed) so it can be re-added later.
* Returns position info so the group can be restored to the same spot.
*/
minimizeDocked(group: PanelGroup): {
siblingGroupId: string;
direction: DockPosition;
} | null;
/**
* Re-add a group to the main split layout at the given dock position,
* relative to a specific sibling group (or main group as fallback).
*/
restoreDocked(group: PanelGroup, position: DockPosition, initialSize?: number, siblingGroupId?: string): void;
/**
* Find the FloatingOverlay that contains a given group
* (either as the owner or inside the internal layout).
*/
private findOverlayForGroup;
/**
* Move a panel's element from its wrapper into a portal entry in the
* portal layer. The wrapper becomes an empty placeholder used for
* position reference. This is the ONLY time the element moves — after
* this the iframe stays in the portal layer forever.
*/
private registerPortal;
private unregisterPortal;
/**
* RAF loop that syncs each portal entry's position to match its
* anchor's bounding rect. Batches reads then writes to avoid
* layout thrashing.
*/
private syncPortals;
}
export {};