/** * 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; } 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) | 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; /** * 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 {};