From 6379362f63c8d9350eee3838b30f937fae0ac7ae Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Fri, 29 May 2026 22:09:20 +0100 Subject: [PATCH 1/6] feat(dockview-core): nested multi-grid layouts for floating groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the multi-root foundation: a DockviewComponent can host more than one gridview root (the main grid plus, now, floating windows), all sharing one groups map, panel registry, DnD transfer namespace and serialization — so there is still a single public DockviewApi and existing behaviour is unchanged. - a floating window mounts its own Gridview as the overlay content; the anchor group is added into it, and further groups can be split in - getGridviewForGroup resolves a group's owning root; doAddGroup / createGroupAtLocation take a target gridview; moveGroup(OrPanel) compute locations against the destination root, so panels/groups can be dragged across the main<->floating boundary (and floating<->floating) - floating groups accept all drop zones (edge drop splits the window); doRemoveGroup is multi-group aware (detach one group, keep the window alive) - serialize/restore each floating window's nested gridview (legacy single-group shape preserved for byte-stable round-trips) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dockview/dockviewComponent.spec.ts | 226 +++++++++++ .../src/dockview/dockviewComponent.ts | 357 ++++++++++++++---- .../dockview/dockviewFloatingGroupPanel.ts | 34 +- .../src/dockview/dockviewGroupPanelModel.ts | 9 +- .../src/dockview/floatingGroupService.ts | 55 ++- .../src/gridview/baseComponentGridview.ts | 5 +- .../dockview-core/src/overlay/overlay.scss | 14 +- 7 files changed, 597 insertions(+), 103 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index ce2687fe2a..e623836b2e 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -5896,6 +5896,232 @@ describe('dockviewComponent', () => { expect(dockview.panels.length).toBe(3); }); + describe('nested floating layout (multi-root)', () => { + const make = () => + new DockviewComponent(document.createElement('div'), { + createComponent(options) { + switch (options.name) { + case 'default': + return new PanelContentPartTest( + options.id, + options.name + ); + default: + throw new Error(`unsupported`); + } + }, + }); + + test('whole-group move splits a floating window rather than spawning a new one', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(dockview.floatingGroups.length).toBe(1); + + // drag the whole grid group to the right of the floating group + dockview.moveGroupOrPanel({ + from: { groupId: panel1.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + + // still a single floating window, now hosting both groups + expect(dockview.floatingGroups.length).toBe(1); + expect(panel1.group.api.location.type).toBe('floating'); + expect(panel2.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(panel1.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.getGridviewForGroup(panel1.group)).not.toBe( + (dockview as any).gridview + ); + }); + + test('panel split into a floating window creates a new group inside it', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1a = dockview.addPanel({ + id: 'panel_1a', + component: 'default', + }); + dockview.addPanel({ + id: 'panel_1b', + component: 'default', + position: { referencePanel: 'panel_1a' }, + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + // split one panel out of the 2-panel grid group into the float + dockview.moveGroupOrPanel({ + from: { + groupId: panel1a.group.id, + panelId: 'panel_1b', + }, + to: { group: panel2.group, position: 'bottom' }, + }); + + const movedPanel = dockview.panels.find( + (p) => p.id === 'panel_1b' + )!; + expect(movedPanel.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(movedPanel.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.floatingGroups.length).toBe(1); + }); + + test('moving a group out of a multi-group window keeps the window alive', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // build a 2-group floating window: split panel3's group in + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + expect(dockview.getGridviewForGroup(panel3.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + + // now move panel3's group back to the main grid (beside panel1) + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + expect(panel3.group.api.location.type).toBe('grid'); + expect(panel2.group.api.location.type).toBe('floating'); + // the window survived because panel2's group still lives in it + expect(dockview.floatingGroups.length).toBe(1); + }); + + test('floating-to-floating move merges windows (source window closes)', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(dockview.floatingGroups.length).toBe(2); + + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'bottom' }, + }); + + // panel3's single-group window closed; both groups now share + // panel2's window + expect(dockview.floatingGroups.length).toBe(1); + expect(panel2.group.api.location.type).toBe('floating'); + expect(panel3.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(panel3.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + }); + + test('single-group floating window serializes in the legacy shape', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const json = dockview.toJSON(); + expect(json.floatingGroups?.length).toBe(1); + expect(json.floatingGroups![0].data).toBeDefined(); + expect(json.floatingGroups![0].grid).toBeUndefined(); + }); + + test('multi-group floating window round-trips through toJSON/fromJSON', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // split panel3's group into the floating window + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + + const json = dockview.toJSON(); + // a multi-group window serializes as a nested grid + expect(json.floatingGroups![0].grid).toBeDefined(); + expect(json.floatingGroups![0].data).toBeUndefined(); + + const restored = make(); + restored.layout(1000, 500); + restored.fromJSON(json); + + expect(restored.panels.length).toBe(3); + expect(restored.floatingGroups.length).toBe(1); + const floatingGroups = restored.groups.filter( + (g) => g.api.location.type === 'floating' + ); + expect(floatingGroups.length).toBe(2); + expect(restored.getGridviewForGroup(floatingGroups[0])).toBe( + restored.getGridviewForGroup(floatingGroups[1]) + ); + expect( + restored.getGridviewForGroup(floatingGroups[0]) + ).not.toBe((restored as any).gridview); + }); + }); + test('move a floating group of many tabs to a new fixed group', () => { const container = document.createElement('div'); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 2d5c91ff6d..fad9cee819 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -4,6 +4,8 @@ import { getGridLocation, ISerializedLeafNode, orthogonal, + Gridview, + SerializedGridview, } from '../gridview/gridview'; import { directionToPosition, Position } from '../dnd/droptarget'; import { tail, sequenceEquals } from '../array'; @@ -35,7 +37,7 @@ import { toTarget, } from '../gridview/baseComponentGridview'; import { DockviewApi } from '../api/component.api'; -import { Orientation } from '../splitview/splitview'; +import { Orientation, Sizing } from '../splitview/splitview'; import { GroupOptions, GroupPanelViewState, @@ -156,7 +158,16 @@ export interface PanelReference { } export interface SerializedFloatingGroup { - data: GroupPanelViewState; + /** + * Legacy single-group form. Still written when a floating window hosts a + * single group (for stable round-trips) and always accepted on read. + */ + data?: GroupPanelViewState; + /** + * Nested layout of the floating window. Written when the window hosts more + * than one group; mutually exclusive with `data`. + */ + grid?: SerializedGridview; position: AnchoredBox; } @@ -1393,22 +1404,81 @@ export class DockviewComponent const anchoredBox = getAnchoredBox(); + // The floating window hosts its own gridview so it can grow into a + // nested splitview layout. The window starts with the single anchor + // group; further groups are added via drag-and-drop. + const floatingGridview = this.createFloatingGridview(); + floatingGridview.addView(group, Sizing.Distribute, [0]); + + this.mountFloatingWindow( + floatingGridview, + group, + [group], + anchoredBox, + { + dragHandle: options?.dragHandle, + inDragMode: options?.inDragMode, + skipActiveGroup: options?.skipActiveGroup, + } + ); + } + + /** Build an empty gridview configured to match the main grid's styling. */ + private createFloatingGridview( + orientation: Orientation = Orientation.HORIZONTAL + ): Gridview { + return new Gridview( + true, + this.options.hideBorders + ? { separatorBorder: 'transparent' } + : undefined, + orientation, + false, + this.options.theme?.gap ?? 0 + ); + } + + /** + * Wrap a (populated) floating gridview in an overlay window: title bar / + * move handle, drag wiring, the floating-group service entry and the + * `floating` location tag for every member group. + */ + private mountFloatingWindow( + floatingGridview: Gridview, + anchorGroup: DockviewGroupPanel, + members: DockviewGroupPanel[], + anchoredBox: AnchoredBox, + options?: { + dragHandle?: 'titlebar' | 'tabbar'; + inDragMode?: boolean; + skipActiveGroup?: boolean; + } + ): void { + const service = assertModule( + this._floatingGroupService, + 'FloatingGroup', + 'api.addFloatingGroup' + ); + if (!service) { + return; + } + const dragHandleMode = options?.dragHandle ?? this.options.floatingGroupDragHandle ?? 'titlebar'; - // `'titlebar'` renders a dedicated grab bar above the group's tab bar - // and uses it as the move handle; `'tabbar'` falls back to the legacy - // behaviour of moving via the tab-bar void container. + // `'titlebar'` renders a dedicated grab bar above the tab bar and uses + // it as the move handle; `'tabbar'` falls back to moving via the + // tab-bar void container. const titleBar = dragHandleMode === 'titlebar' - ? new FloatingTitleBar(this, group) + ? new FloatingTitleBar(this, anchorGroup) : undefined; const overlay = new Overlay({ container: this._floatingOverlayHost ?? this.gridview.element, - content: group.element, + content: floatingGridview.element, header: titleBar?.element, ...anchoredBox, minimumInViewportWidth: @@ -1427,7 +1497,7 @@ export class DockviewComponent const dragHandle = titleBar?.element ?? - group.element.querySelector('.dv-void-container'); + anchorGroup.element.querySelector('.dv-void-container'); if (!dragHandle) { throw new Error('dockview: failed to find drag handle'); @@ -1440,10 +1510,14 @@ export class DockviewComponent : false, }); - const floatingGroupPanel = service.add(group, overlay); + const floatingGroupPanel = service.add( + anchorGroup, + overlay, + floatingGridview + ); if (titleBar) { - // Tie the title bar's lifetime to the floating group and surface + // Tie the title bar's lifetime to the floating window and surface // its redock drag through the same public `onWillDragGroup` event // the tab-bar handle uses. floatingGroupPanel.addDisposables( @@ -1451,16 +1525,18 @@ export class DockviewComponent titleBar.onDragStart((event) => { this._onWillDragGroup.fire({ nativeEvent: event, - group, + group: anchorGroup, }); }) ); } - group.model.location = { type: 'floating' }; + for (const member of members) { + member.model.location = { type: 'floating' }; + } if (!options?.skipActiveGroup) { - this.doSetGroupAndPanelActive(group); + this.doSetGroupAndPanelActive(anchorGroup); } } @@ -2097,17 +2173,49 @@ export class DockviewComponent const serializedFloatingGroups = data.floatingGroups ?? []; for (const serializedFloatingGroup of serializedFloatingGroups) { - const { data, position } = serializedFloatingGroup; + const { data, grid, position } = serializedFloatingGroup; - const group = createGroupFromSerializedState(data); + if (grid) { + // Multi-group window: rebuild the window's nested gridview + // from its serialized tree. + const floatingGridview = this.createFloatingGridview( + grid.orientation + ); + const members: DockviewGroupPanel[] = []; + floatingGridview.deserialize(grid, { + fromJSON: ( + node: ISerializedLeafNode + ) => { + const group = createGroupFromSerializedState( + node.data + ); + members.push(group); + return group; + }, + }); - this.addFloatingGroup(group, { - position: position, - width: position.width, - height: position.height, - skipRemoveGroup: true, - inDragMode: false, - }); + if (members.length === 0) { + continue; + } + + this.mountFloatingWindow( + floatingGridview, + members[0], + members, + position, + { inDragMode: false } + ); + } else if (data) { + const group = createGroupFromSerializedState(data); + + this.addFloatingGroup(group, { + position: position, + width: position.width, + height: position.height, + skipRemoveGroup: true, + inDragMode: false, + }); + } } const serializedPopoutGroups = data.popoutGroups ?? []; @@ -2628,8 +2736,43 @@ export class DockviewComponent this._floatingGroupService?.findByGroup(group); if (floatingGroup) { + const members = this.floatingWindowMembers(group); + + if (members.length > 1) { + // The floating window hosts other groups — remove just this + // group's view from the window's nested gridview and keep + // the window alive. + floatingGroup.gridview.remove(group); + + if (floatingGroup.group === group) { + // The anchor left; promote a remaining member. + floatingGroup.setAnchorGroup( + members.find((m) => m !== group)! + ); + } + + if (!options?.skipDispose) { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } else { + // Relocation: reset location so the destination root + // can re-tag it. + group.model.location = { type: 'grid' }; + } + + if (!options?.skipActive && this._activeGroup === group) { + const groups = Array.from(this._groups.values()); + this.doSetGroupAndPanelActive( + groups.length > 0 ? groups[0].value : undefined + ); + } + + return group; + } + if (!options?.skipDispose) { - floatingGroup.group.dispose(); + group.dispose(); this._groups.delete(group.id); this._onDidRemoveGroup.fire(group); } @@ -2645,7 +2788,7 @@ export class DockviewComponent ); } - return floatingGroup.group; + return group; } throw new Error('dockview: failed to find floating group'); @@ -2825,9 +2968,15 @@ export class DockviewComponent * into an adjacent group */ + // The destination group may live in the main grid or in a floating + // window's nested gridview — resolve which root we are dropping + // into so locations/orientation are computed against it. + const destinationGridview = + this.getGridviewForGroup(destinationGroup); + const referenceLocation = getGridLocation(destinationGroup.element); const targetLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, referenceLocation, destinationTarget ); @@ -2840,7 +2989,10 @@ export class DockviewComponent const [targetParentLocation, to] = tail(targetLocation); - if (sourceGroup.api.location.type === 'grid') { + if ( + sourceGroup.api.location.type === 'grid' && + destinationGridview === this.gridview + ) { const sourceLocation = getGridLocation(sourceGroup.element); const [sourceParentLocation, from] = tail(sourceLocation); @@ -2896,13 +3048,16 @@ export class DockviewComponent this.doRemoveGroup(sourceGroup, { skipActive: true }); const updatedTargetLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, getGridLocation(destinationGroup.element), destinationTarget ); const newGroup = this.createGroupAtLocation( - updatedTargetLocation + updatedTargetLocation, + undefined, + undefined, + destinationGridview ); this.movingLock(() => newGroup.model.openPanel(removedPanel, { @@ -2938,7 +3093,12 @@ export class DockviewComponent ); } - const newGroup = this.createGroupAtLocation(targetLocation); + const newGroup = this.createGroupAtLocation( + targetLocation, + undefined, + undefined, + destinationGridview + ); this.movingLock(() => newGroup.model.openPanel(removedPanel, { skipSetGroupActive: true, @@ -2967,11 +3127,19 @@ export class DockviewComponent ); const location = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, updatedReferenceLocation, destinationTarget ); - this.movingLock(() => this.doAddGroup(targetGroup, location)); + this.movingLock(() => + this.doAddGroup( + targetGroup, + location, + undefined, + destinationGridview + ) + ); + this.setGroupLocationForRoot(targetGroup, destinationGridview); this.doSetGroupAndPanelActive(targetGroup); this._onDidMovePanel.fire({ @@ -2998,12 +3166,17 @@ export class DockviewComponent } const dropLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, referenceLocation, destinationTarget ); - const group = this.createGroupAtLocation(dropLocation); + const group = this.createGroupAtLocation( + dropLocation, + undefined, + undefined, + destinationGridview + ); this.movingLock(() => group.model.openPanel(removedPanel, { skipSetGroupActive: true, @@ -3280,7 +3453,21 @@ export class DockviewComponent 'dockview: failed to find floating group' ); } - selectedFloatingGroup.dispose(); + + const members = this.floatingWindowMembers(from); + if (members.length > 1) { + // Detach just this group from the floating window's + // nested gridview; keep the window (and its other + // groups) alive. + selectedFloatingGroup.gridview.remove(from); + if (selectedFloatingGroup.group === from) { + selectedFloatingGroup.setAnchorGroup( + members.find((m) => m !== from)! + ); + } + } else { + selectedFloatingGroup.dispose(); + } break; } case 'popout': { @@ -3333,20 +3520,24 @@ export class DockviewComponent } } - // For moves to grid locations - if (to.api.location.type === 'grid') { + // Place `source` next to `to`, in whichever gridview root `to` + // lives in. When `to` is inside a floating window this splits the + // window's nested layout rather than spawning a new window. + if ( + to.api.location.type === 'grid' || + to.api.location.type === 'floating' + ) { + const destGridview = this.getGridviewForGroup(to); const referenceLocation = getGridLocation(to.element); const dropLocation = getRelativeLocation( - this.gridview.orientation, + destGridview.orientation, referenceLocation, target ); - // Add to grid for all moves targeting grid location - let size: number; - switch (this.gridview.orientation) { + switch (destGridview.orientation) { case Orientation.VERTICAL: size = referenceLocation.length % 2 == 0 @@ -3361,42 +3552,8 @@ export class DockviewComponent break; } - this.gridview.addView(source, size, dropLocation); - } else if (to.api.location.type === 'floating') { - // For moves to floating locations, add as floating group - // Get the position/size from the target floating group - const targetFloatingGroup = - this._floatingGroupService?.findByGroup(to); - if (targetFloatingGroup) { - const box = targetFloatingGroup.overlay.toJSON(); - - // Calculate position based on available properties - let left: number, top: number; - if ('left' in box) { - left = box.left + 50; - } else if ('right' in box) { - left = Math.max(0, box.right - box.width - 50); - } else { - left = 50; // Default fallback - } - - if ('top' in box) { - top = box.top + 50; - } else if ('bottom' in box) { - top = Math.max(0, box.bottom - box.height - 50); - } else { - top = 50; // Default fallback - } - - this.addFloatingGroup(source, { - height: box.height, - width: box.width, - position: { - left, - top, - }, - }); - } + destGridview.addView(source, size, dropLocation); + this.setGroupLocationForRoot(source, destGridview); } } @@ -3612,13 +3769,57 @@ export class DockviewComponent private createGroupAtLocation( location: number[], size?: number, - options?: GroupOptions + options?: GroupOptions, + gridview: Gridview = this.gridview ): DockviewGroupPanel { const group = this.createGroup(options); - this.doAddGroup(group, location, size); + this.doAddGroup(group, location, size, gridview); + this.setGroupLocationForRoot(group, gridview); return group; } + /** + * Tag a group with the location type matching the gridview root it now + * lives in (main grid vs a floating window). Floating and grid groups + * share the main render/drop-target containers, so only `location` differs. + */ + private setGroupLocationForRoot( + group: DockviewGroupPanel, + gridview: Gridview + ): void { + group.model.location = + gridview === this.gridview + ? { type: 'grid' } + : { type: 'floating' }; + } + + /** + * Resolve which gridview root currently owns a group: the main grid, or + * the nested gridview of the floating window it lives in. + */ + getGridviewForGroup(group: DockviewGroupPanel): Gridview { + return ( + this._floatingGroupService?.findByGroup(group)?.gridview ?? + this.gridview + ); + } + + /** + * The groups that live within the same floating window as `group` + * (including `group` itself). Empty when `group` is not floating. + */ + private floatingWindowMembers( + group: DockviewGroupPanel + ): DockviewGroupPanel[] { + const floating = this._floatingGroupService?.findByGroup(group); + if (!floating) { + return []; + } + return this.groups.filter((candidate) => + floating.gridview.element.contains(candidate.element) + ); + } + private findGroup(panel: IDockviewPanel): DockviewGroupPanel | undefined { return Array.from(this._groups.values()).find((group) => group.value.model.containsPanel(panel) diff --git a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts index 62c51c8cc8..9208e0ec93 100644 --- a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts @@ -1,5 +1,6 @@ import { Overlay } from '../overlay/overlay'; import { CompositeDisposable } from '../lifecycle'; +import { Gridview } from '../gridview/gridview'; import { AnchoredBox } from '../types'; import { DockviewGroupPanel, IDockviewGroupPanel } from './dockviewGroupPanel'; @@ -12,12 +13,39 @@ export class DockviewFloatingGroupPanel extends CompositeDisposable implements IDockviewFloatingGroupPanel { + private _group: DockviewGroupPanel; + + /** + * The window's representative/anchor group. A floating window can host a + * nested layout of several groups; the anchor is used for back-compat + * single-group APIs and is reassigned if it leaves the window. + */ + get group(): DockviewGroupPanel { + return this._group; + } + + setAnchorGroup(group: DockviewGroupPanel): void { + this._group = group; + } + constructor( - readonly group: DockviewGroupPanel, - readonly overlay: Overlay + group: DockviewGroupPanel, + readonly overlay: Overlay, + /** + * The floating window hosts its own gridview so it can hold a nested + * splitview layout of groups, not just a single group. + */ + readonly gridview: Gridview ) { super(); - this.addDisposables(overlay); + this._group = group; + this.addDisposables(overlay, { + // The gridview owns the floating window's DOM subtree (mounted as + // the overlay's content). Disposing it tears down the splitview; + // it does NOT dispose the leaf views (groups) — their lifecycle is + // owned by the component's `_groups` map. + dispose: () => this.gridview.dispose(), + }); } position(bounds: Partial): void { diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 883de270b0..2f023ad390 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -458,12 +458,9 @@ export class DockviewGroupPanelModel applyZones(['top', 'bottom', 'left', 'right', 'center']); break; case 'floating': - applyZones(['center']); - applyZones( - value - ? ['center'] - : ['top', 'bottom', 'left', 'right', 'center'] - ); + // Floating windows host their own nested gridview, so an edge + // drop splits the window's layout just like the main grid. + applyZones(['top', 'bottom', 'left', 'right', 'center']); toggleClass(this.container, 'dv-groupview-floating', true); diff --git a/packages/dockview-core/src/dockview/floatingGroupService.ts b/packages/dockview-core/src/dockview/floatingGroupService.ts index 322579cdcb..68773da8d6 100644 --- a/packages/dockview-core/src/dockview/floatingGroupService.ts +++ b/packages/dockview-core/src/dockview/floatingGroupService.ts @@ -1,6 +1,7 @@ import { CompositeDisposable, IDisposable } from '../lifecycle'; import { remove } from '../array'; import { watchElementResize } from '../dom'; +import { Gridview, ISerializedLeafNode } from '../gridview/gridview'; import { Overlay } from '../overlay/overlay'; import { DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE } from '../constants'; import { DockviewFloatingGroupPanel } from './dockviewFloatingGroupPanel'; @@ -23,7 +24,8 @@ export interface IFloatingGroupService extends IDisposable { add( group: DockviewGroupPanel, - overlay: Overlay + overlay: Overlay, + gridview: Gridview ): DockviewFloatingGroupPanel; findByGroup( @@ -53,11 +55,13 @@ export class FloatingGroupService implements IFloatingGroupService { add( group: DockviewGroupPanel, - overlay: Overlay + overlay: Overlay, + gridview: Gridview ): DockviewFloatingGroupPanel { const floatingGroupPanel = new DockviewFloatingGroupPanel( group, - overlay + overlay, + gridview ); const disposable = new CompositeDisposable( @@ -67,9 +71,13 @@ export class FloatingGroupService implements IFloatingGroupService { } }), (() => { + // The floating window's nested gridview fills the overlay + // beneath the (optional) title bar; size it from its own + // measured box so it follows the overlay as the user drags + // / resizes the window. let lastWidth = -1; let lastHeight = -1; - return watchElementResize(group.element, (entry) => { + return watchElementResize(gridview.element, (entry) => { const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); if (width === lastWidth && height === lastHeight) { @@ -77,14 +85,14 @@ export class FloatingGroupService implements IFloatingGroupService { } lastWidth = width; lastHeight = height; - group.layout(width, height); + gridview.layout(width, height); }); })() ); floatingGroupPanel.addDisposables( overlay.onDidChange(() => { - group.layout(group.width, group.height); + gridview.layout(gridview.width, gridview.height); }), overlay.onDidChangeEnd(() => { this._host.fireLayoutChange(); @@ -118,16 +126,41 @@ export class FloatingGroupService implements IFloatingGroupService { findByGroup( group: DockviewGroupPanel ): DockviewFloatingGroupPanel | undefined { + // A floating window may host several groups in a nested gridview, so + // match by membership (DOM containment) rather than only the anchor + // group. `floating.group === group` covers the brief window before the + // anchor's element is attached to the gridview. return this._floatingGroups.find( - (floating) => floating.group === group + (floating) => + floating.group === group || + floating.gridview.element.contains(group.element) ); } serialize(): SerializedFloatingGroup[] { - return this._floatingGroups.map((group) => ({ - data: group.group.toJSON() as GroupPanelViewState, - position: group.overlay.toJSON(), - })); + return this._floatingGroups.map((floating) => { + const grid = floating.gridview.serialize(); + const position = floating.overlay.toJSON(); + const root = grid.root; + + // A single-group window keeps the legacy `data` shape so layouts + // round-trip byte-stably and older readers keep working; only + // genuine multi-group windows emit the nested `grid` form. + if ( + root.type === 'branch' && + root.data.length === 1 && + root.data[0].type === 'leaf' + ) { + return { + data: ( + root.data[0] as ISerializedLeafNode + ).data, + position, + }; + } + + return { grid, position }; + }); } constrainBounds(): void { diff --git a/packages/dockview-core/src/gridview/baseComponentGridview.ts b/packages/dockview-core/src/gridview/baseComponentGridview.ts index 9cd0bd05b0..f409497e8e 100644 --- a/packages/dockview-core/src/gridview/baseComponentGridview.ts +++ b/packages/dockview-core/src/gridview/baseComponentGridview.ts @@ -274,9 +274,10 @@ export abstract class BaseGrid protected doAddGroup( group: T, location: number[] = [0], - size?: number + size?: number, + gridview: Gridview = this.gridview ): void { - this.gridview.addView(group, size ?? Sizing.Distribute, location); + gridview.addView(group, size ?? Sizing.Distribute, location); this._onDidAdd.fire(group); } diff --git a/packages/dockview-core/src/overlay/overlay.scss b/packages/dockview-core/src/overlay/overlay.scss index 8793df09e9..edb94f17dd 100644 --- a/packages/dockview-core/src/overlay/overlay.scss +++ b/packages/dockview-core/src/overlay/overlay.scss @@ -34,9 +34,17 @@ } } +// A floating window hosts its own gridview as the overlay content; it must +// fill the resize container so its size tracks the overlay box. +.dv-resize-container > .dv-grid-view { + width: 100%; + height: 100%; +} + // When a dedicated drag-handle title bar is present the overlay stacks the -// handle above the group as a flex column so the group shrinks to fit beneath -// it. Resize handles are position:absolute and stay out of the flex flow. +// handle above the nested gridview as a flex column so the layout shrinks to +// fit beneath it. Resize handles are position:absolute and stay out of the +// flex flow. .dv-resize-container-with-titlebar { display: flex; flex-direction: column; @@ -45,7 +53,7 @@ flex: 0 0 auto; } - > .dv-groupview { + > .dv-grid-view { height: auto; flex: 1 1 0; min-height: 0; From 42a4f775b499527e7ce52b328f5b87e82aae4ac9 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Fri, 29 May 2026 22:09:59 +0100 Subject: [PATCH 2/6] feat(dockview-core): nested multi-grid layouts for popout windows Extend the multi-root model to popout windows: a popout hosts its own Gridview (in the popout document, with its own overlay render + drop-target containers), so it can hold a nested splitview of groups and participate in cross-boundary drag-and-drop just like floating windows. - popout groups accept all drop zones; moveGroup(OrPanel) and doRemoveGroup are popout-root aware (split into a popout, drag in/out, multi-group windows) - getOverrideTarget resolves dynamically so a relocated group's drop overlay mounts in the correct window - serialize/restore the popout's nested gridview (legacy single-group shape preserved); restore rebuilds it via an internal override - size + reposition the popout via a ResizeObserver created in the popout window's own realm, so content lays out at the real size once the window has painted (a parent-realm observer / fixed animation-frame count raced) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dockview/dockviewComponent.spec.ts | 63 +++ .../dockview/dockviewGroupPanelModel.spec.ts | 6 +- .../src/dockview/components/panel/content.ts | 10 +- .../src/dockview/dockviewComponent.ts | 420 +++++++++++++++--- .../src/dockview/dockviewGroupPanelModel.ts | 4 +- .../src/dockview/popoutWindowService.ts | 60 ++- 6 files changed, 482 insertions(+), 81 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index e623836b2e..7f9a9fe08c 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -6075,6 +6075,69 @@ describe('dockviewComponent', () => { expect(json.floatingGroups![0].grid).toBeUndefined(); }); + test('a group can be split into a popout window', async () => { + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + expect(panel1.api.location.type).toBe('popout'); + + // drag panel2's group onto the right edge of the popout group + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + expect(panel2.api.location.type).toBe('popout'); + expect(dockview.getGridviewForGroup(panel1.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.getGridviewForGroup(panel1.group)).not.toBe( + (dockview as any).gridview + ); + + dockview.dispose(); + }); + + test('a multi-group popout window serializes with a nested grid', async () => { + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + const json = dockview.toJSON(); + expect(json.popoutGroups![0].grid).toBeDefined(); + expect(json.popoutGroups![0].data).toBeUndefined(); + + dockview.dispose(); + }); + test('multi-group floating window round-trips through toJSON/fromJSON', () => { const dockview = make(); dockview.layout(1000, 500); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 45dc1d00b5..fa552e4568 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -2076,7 +2076,11 @@ describe('dockviewGroupPanelModel', () => { test.each([ ['grid', ['top', 'bottom', 'left', 'right', 'center']], - ['popout', ['center']], + // floating + popout host their own nested gridview, so they accept + // all zones (edge drops split the window). Edge groups are + // structural and stay center-only. + ['floating', ['top', 'bottom', 'left', 'right', 'center']], + ['popout', ['top', 'bottom', 'left', 'right', 'center']], ['edge', ['center']], ] as const)( 'location=%s applies zones to BOTH dropTarget and pointerDropTarget', diff --git a/packages/dockview-core/src/dockview/components/panel/content.ts b/packages/dockview-core/src/dockview/components/panel/content.ts index b511d299aa..6611b8e5b6 100644 --- a/packages/dockview-core/src/dockview/components/panel/content.ts +++ b/packages/dockview-core/src/dockview/components/panel/content.ts @@ -63,7 +63,11 @@ export class ContentContainer this.addDisposables(this._onDidFocus, this._onDidBlur); - const target = group.dropTargetContainer; + // Resolve the override anchor dynamically: a group can be relocated + // between roots (grid / floating / popout) after construction, and the + // popout anchor in particular lives in another window — a value + // captured here would mount overlays in the wrong window. + const getOverrideTarget = () => group.dropTargetContainer?.model; const canDisplayOverlay = ( event: DragEvent | PointerEvent, @@ -105,7 +109,7 @@ export class ContentContainer className: 'dv-drop-target-content', acceptedTargetZones: ['top', 'bottom', 'left', 'right', 'center'], canDisplayOverlay, - getOverrideTarget: target ? () => target.model : undefined, + getOverrideTarget, }); this.pointerDropTarget = pointerBackend.createDropTarget(this.element, { @@ -117,7 +121,7 @@ export class ContentContainer : null; }, className: 'dv-drop-target-content', - getOverrideTarget: target ? () => target.model : undefined, + getOverrideTarget, }); this.addDisposables(this.dropTarget, this.pointerDropTarget); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index fad9cee819..d81a5a1cf8 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -152,6 +152,14 @@ export interface DockviewPopoutGroupOptions { overridePopoutGroup?: DockviewGroupPanel; } +interface DockviewPopoutGroupOptionsInternal extends DockviewPopoutGroupOptions { + /** + * Restore into a pre-built nested gridview (multi-group popout window) + * rather than creating one around a single group. + */ + overridePopoutGridview?: Gridview; +} + export interface PanelReference { update: (event: { params: { [key: string]: any } }) => void; remove: () => void; @@ -172,7 +180,16 @@ export interface SerializedFloatingGroup { } export interface SerializedPopoutGroup { - data: GroupPanelViewState; + /** + * Legacy single-group form. Still written when a popout window hosts a + * single group (for stable round-trips) and always accepted on read. + */ + data?: GroupPanelViewState; + /** + * Nested layout of the popout window. Written when the window hosts more + * than one group; mutually exclusive with `data`. + */ + grid?: SerializedGridview; url?: string; gridReferenceGroup?: string; position: Box | null; @@ -898,7 +915,7 @@ export class DockviewComponent addPopoutGroup( itemToPopout: DockviewPanel | DockviewGroupPanel, - options?: DockviewPopoutGroupOptions + options?: DockviewPopoutGroupOptionsInternal ): Promise { const service = assertModule( this._popoutWindowService, @@ -996,7 +1013,11 @@ export class DockviewComponent let group: DockviewGroupPanel; - if (!isGroupAddedToDom) { + if (options?.overridePopoutGridview) { + // Restoring a multi-group window: the anchor group is + // already built inside the supplied gridview. + group = options.overridePopoutGroup ?? referenceGroup; + } else if (!isGroupAddedToDom) { group = referenceGroup; } else if (options?.overridePopoutGroup) { group = options.overridePopoutGroup; @@ -1041,14 +1062,45 @@ export class DockviewComponent ); group.model.renderContainer = overlayRenderContainer; - group.layout( + + // The popout window hosts its own gridview so it can grow into + // a nested splitview layout. The window starts with the single + // anchor group; further groups arrive via drag-and-drop. On + // restore a pre-populated gridview is supplied instead. + const popoutGridview = + options?.overridePopoutGridview ?? + this.createNestedGridview(); + if (!options?.overridePopoutGridview) { + popoutGridview.addView(group, Sizing.Distribute, [0]); + } + // Fill the popout window. Unlike the main grid (explicit px) and + // floating windows (CSS inside .dv-resize-container), the popout + // gridview has no sizing context, so without this it collapses + // to 0 height and nothing renders. + popoutGridview.element.style.width = '100%'; + popoutGridview.element.style.height = '100%'; + popoutGridview.layout( _window.window!.innerWidth, _window.window!.innerHeight ); + // Guarded so the teardown's re-entrant paths (window close + // re-enters via the anchor's doRemoveGroup) never double-dispose. + let popoutGridviewDisposed = false; + const disposePopoutGridview = () => { + if (!popoutGridviewDisposed) { + popoutGridviewDisposed = true; + popoutGridview.dispose(); + } + }; + let floatingBox: AnchoredBox | undefined; - if (!options?.overridePopoutGroup && isGroupAddedToDom) { + if ( + !options?.overridePopoutGroup && + !options?.overridePopoutGridview && + isGroupAddedToDom + ) { if (itemToPopout instanceof DockviewPanel) { this.movingLock(() => { const panel = @@ -1088,7 +1140,7 @@ export class DockviewComponent popoutContainer.style.overflow = 'hidden'; popoutContainer.appendChild(gready); - popoutContainer.appendChild(group.element); + popoutContainer.appendChild(popoutGridview.element); const anchor = document.createElement('div'); const dropTargetContainer = new DropTargetAnchorContainer( @@ -1122,6 +1174,27 @@ export class DockviewComponent popoutUrl: options?.popoutUrl, }; + if (options?.overridePopoutGridview) { + // Restored multi-group window. Wire every member (including + // the anchor) to this window's containers and popout + // location now that the gridview is attached and laid out — + // re-setting renderContainer forces a re-render at the right + // time so 'always'-rendered content positions in this + // window rather than where it was first created. + const members = this.groups.filter((candidate) => + popoutGridview.element.contains(candidate.element) + ); + for (const member of members) { + member.model.renderContainer = overlayRenderContainer; + member.model.dropTargetContainer = dropTargetContainer; + member.model.location = { + type: 'popout', + getWindow: () => _window.window!, + popoutUrl: options?.popoutUrl, + }; + } + } + if ( isGroupAddedToDom && itemToPopout.api.location.type === 'grid' @@ -1131,6 +1204,60 @@ export class DockviewComponent this.doSetGroupAndPanelActive(group); + // The popout window's innerWidth/innerHeight are often 0/stale + // until it has painted, and the nested gridview lays its + // children out to the size passed to layout() (a plain group + // fills via CSS instead). To stop content rendering into a zero + // box until a manual resize — and to avoid the race a fixed + // number of animation frames had — observe the gridview element + // with a ResizeObserver created in the POPOUT window's OWN + // realm. A parent-realm observer fires unreliably across the + // window boundary; a same-realm one fires reliably, including + // the initial observation once the window is sized. + const PopoutResizeObserver = ( + _window.window as (Window & typeof globalThis) | undefined + )?.ResizeObserver; + if (PopoutResizeObserver) { + let lastWidth = -1; + let lastHeight = -1; + const relayout = () => { + const win = _window.window; + if (this.isDisposed || !win || win.closed) { + return; + } + const width = Math.round( + popoutGridview.element.clientWidth + ); + const height = Math.round( + popoutGridview.element.clientHeight + ); + if (width === lastWidth && height === lastHeight) { + return; + } + lastWidth = width; + lastHeight = height; + if (width > 0 && height > 0) { + popoutGridview.layout(width, height); + } + overlayRenderContainer.updateAllPositions(); + }; + const observer = new PopoutResizeObserver(() => { + // Defer out of the observer callback into the popout's + // own frame to size against the settled layout and to + // avoid resize-loop warnings. + const raf = _window.window?.requestAnimationFrame; + if (raf) { + raf.call(_window.window, relayout); + } else { + relayout(); + } + }); + observer.observe(popoutGridview.element); + popoutWindowDisposable.addDisposables({ + dispose: () => observer.disconnect(), + }); + } + popoutWindowDisposable.addDisposables( group.api.onDidActiveChange((event) => { if (event.isActive) { @@ -1152,6 +1279,11 @@ export class DockviewComponent const value = { window: _window, popoutGroup: group, + gridview: popoutGridview, + overlayRenderContainer, + dropTargetContainer, + getWindow: () => _window.window!, + popoutUrl: options?.popoutUrl, referenceGroup: isValidReferenceGroup ? referenceGroup.id : undefined, @@ -1183,13 +1315,8 @@ export class DockviewComponent group, }); }), - /** - * ResizeObserver seems slow here, I do not know why but we don't need it - * since we can reply on the window resize event as we will occupy the full - * window dimensions - */ addDisposableListener(_window.window!, 'resize', () => { - group.layout( + popoutGridview.layout( _window.window!.innerWidth, _window.window!.innerHeight ); @@ -1197,7 +1324,42 @@ export class DockviewComponent overlayRenderContainer, Disposable.from(() => { if (this.isDisposed) { - return; // cleanup may run after instance is disposed + // cleanup may run after instance is disposed; just + // tear down the nested gridview. + disposePopoutGridview(); + return; + } + + // A popout window may host several groups. On a genuine + // window close (the entry is still tracked), relocate + // every non-anchor member back to the main grid before + // the anchor logic below handles `group`. Explicit + // removal paths remove the entry first, so this is + // skipped for them. + if (service.findByGroup(group)) { + const extraMembers = this.groups.filter( + (candidate) => + candidate !== group && + popoutGridview.element.contains( + candidate.element + ) + ); + + for (const member of extraMembers) { + this.movingLock(() => { + this.doRemoveGroup(member, { + skipDispose: true, + skipActive: true, + skipPopoutReturn: true, + }); + member.model.renderContainer = + this.overlayRenderContainer; + member.model.dropTargetContainer = + this.rootDropTargetContainer; + member.model.location = { type: 'grid' }; + this.doAddGroup(member, [0]); + }); + } } if ( @@ -1236,6 +1398,7 @@ export class DockviewComponent * being explicitly removed we can check if this popout group is still tracked by * the popout window service. */ + disposePopoutGridview(); return; } @@ -1261,6 +1424,11 @@ export class DockviewComponent } this.doSetGroupAndPanelActive(group); } + + // All members have been relocated out; tear down the + // window's nested gridview (does not dispose the leaf + // views — their lifecycle stays with `_groups`). + disposePopoutGridview(); }) ); @@ -1407,7 +1575,7 @@ export class DockviewComponent // The floating window hosts its own gridview so it can grow into a // nested splitview layout. The window starts with the single anchor // group; further groups are added via drag-and-drop. - const floatingGridview = this.createFloatingGridview(); + const floatingGridview = this.createNestedGridview(); floatingGridview.addView(group, Sizing.Distribute, [0]); this.mountFloatingWindow( @@ -1423,8 +1591,11 @@ export class DockviewComponent ); } - /** Build an empty gridview configured to match the main grid's styling. */ - private createFloatingGridview( + /** + * Build an empty gridview configured to match the main grid's styling, for + * hosting a nested layout inside a floating or popout window. + */ + private createNestedGridview( orientation: Orientation = Orientation.HORIZONTAL ): Gridview { return new Gridview( @@ -2178,7 +2349,7 @@ export class DockviewComponent if (grid) { // Multi-group window: rebuild the window's nested gridview // from its serialized tree. - const floatingGridview = this.createFloatingGridview( + const floatingGridview = this.createNestedGridview( grid.orientation ); const members: DockviewGroupPanel[] = []; @@ -2232,10 +2403,35 @@ export class DockviewComponent // Queue popup group creation with delays to avoid browser blocking const popoutPromises = popoutService ? serializedPopoutGroups.map((serializedPopoutGroup, index) => { - const { data, position, gridReferenceGroup, url } = + const { data, grid, position, gridReferenceGroup, url } = serializedPopoutGroup; - const group = createGroupFromSerializedState(data); + // Multi-group popout windows rebuild their nested gridview + // from the serialized tree; single-group windows use the + // legacy single-group path. + let overridePopoutGridview: Gridview | undefined; + const members: DockviewGroupPanel[] = []; + + if (grid) { + overridePopoutGridview = this.createNestedGridview( + grid.orientation + ); + overridePopoutGridview.deserialize(grid, { + fromJSON: ( + node: ISerializedLeafNode + ) => { + const member = createGroupFromSerializedState( + node.data + ); + members.push(member); + return member; + }, + }); + } + + const group = grid + ? members[0] + : createGroupFromSerializedState(data!); return popoutService.scheduleRestoration( index * DESERIALIZATION_POPOUT_DELAY_MS, @@ -2245,6 +2441,7 @@ export class DockviewComponent overridePopoutGroup: gridReferenceGroup ? group : undefined, + overridePopoutGridview, referenceGroup: gridReferenceGroup ? this.getPanel(gridReferenceGroup) : undefined, @@ -2257,19 +2454,23 @@ export class DockviewComponent // window never ran. Dispose the orphan here so the // next clear() doesn't trip over an unparented // element. See issue #1304. - if ( - !this.isDisposed && - this._groups.has(group.id) && - group.element.parentElement === null - ) { - for (const panel of [...group.panels]) { - this.removePanel(panel, { - removeEmptyGroup: false, - }); + for (const orphan of members.length > 0 + ? members + : [group]) { + if ( + !this.isDisposed && + this._groups.has(orphan.id) && + orphan.element.parentElement === null + ) { + for (const panel of [...orphan.panels]) { + this.removePanel(panel, { + removeEmptyGroup: false, + }); + } + orphan.dispose(); + this._groups.delete(orphan.id); + this._onDidRemoveGroup.fire(orphan); } - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); } } ); @@ -2736,7 +2937,7 @@ export class DockviewComponent this._floatingGroupService?.findByGroup(group); if (floatingGroup) { - const members = this.floatingWindowMembers(group); + const members = this.nestedWindowMembers(group); if (members.length > 1) { // The floating window hosts other groups — remove just this @@ -2798,6 +2999,41 @@ export class DockviewComponent const selectedGroup = this._popoutWindowService?.findByGroup(group); if (selectedGroup) { + const members = this.nestedWindowMembers(group); + + if (members.length > 1) { + // The popout window hosts other groups — remove just this + // group's view from the window's nested gridview and keep + // the window alive. + selectedGroup.gridview.remove(group); + + if (selectedGroup.popoutGroup === group) { + // The anchor left; promote a remaining member. + selectedGroup.popoutGroup = members.find( + (m) => m !== group + )!; + } + + if (!options?.skipDispose) { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } else { + // Relocation: reset location so the destination root + // can re-tag it. + group.model.location = { type: 'grid' }; + } + + if (!options?.skipActive && this._activeGroup === group) { + const groups = Array.from(this._groups.values()); + this.doSetGroupAndPanelActive( + groups.length > 0 ? groups[0].value : undefined + ); + } + + return group; + } + if (!options?.skipDispose) { if (!options?.skipPopoutAssociated) { const refGroup = selectedGroup.referenceGroup @@ -2859,6 +3095,13 @@ export class DockviewComponent this._updatePositionsFrameId = undefined; this.overlayRenderContainer.updateAllPositions(); + + // Popout windows have their own render containers; reposition those + // too so panels moved/split within a popout are laid out (the main + // container only covers grid + floating, which share it). + for (const entry of this._popoutWindowService?.entries ?? []) { + entry.overlayRenderContainer.updateAllPositions(); + } }); } @@ -3016,9 +3259,13 @@ export class DockviewComponent } } - if (sourceGroup.api.location.type === 'popout') { + if ( + sourceGroup.api.location.type === 'popout' && + this.nestedWindowMembers(sourceGroup).length <= 1 + ) { /** - * the source group is a popout group with a single panel + * the source group is the only group in a popout window and + * has a single panel * * 1. remove the panel from the group without triggering any events * 2. remove the popout group — this may cascade-remove the empty @@ -3026,6 +3273,9 @@ export class DockviewComponent * doRemoveGroup for popout groups), which can shift grid indices * 3. recompute the target location now that the grid is stable * 4. create a new group at the recomputed location and add that panel + * + * Multi-group popout windows fall through to the generic + * detach-and-re-add path so the window stays alive. */ const popoutGroup = @@ -3454,7 +3704,7 @@ export class DockviewComponent ); } - const members = this.floatingWindowMembers(from); + const members = this.nestedWindowMembers(from); if (members.length > 1) { // Detach just this group from the floating window's // nested gridview; keep the window (and its other @@ -3479,7 +3729,23 @@ export class DockviewComponent ); } - // Remove from popout groups list to prevent automatic restoration + const members = this.nestedWindowMembers(from); + if (members.length > 1) { + // Detach just this group from the popout window's + // nested gridview; keep the window + its other + // groups alive. Destination containers/location are + // applied by the placement block below. + selectedPopoutGroup.gridview.remove(from); + if (selectedPopoutGroup.popoutGroup === from) { + selectedPopoutGroup.popoutGroup = members.find( + (m) => m !== from + )!; + } + break; + } + + // Last group leaving — tear the window down. Remove from + // the service first to prevent automatic restoration. this._popoutWindowService?.remove(selectedPopoutGroup); // Clean up the reference group (ghost) if it exists and is hidden @@ -3497,35 +3763,23 @@ export class DockviewComponent } } - // Manually dispose the window without triggering restoration + // Dispose the window without triggering restoration. The + // placement block below applies the destination + // location and containers to `from`. selectedPopoutGroup.window.dispose(); - // Update group's location and containers for target - if (to.api.location.type === 'grid') { - from.model.renderContainer = - this.overlayRenderContainer; - from.model.dropTargetContainer = - this.rootDropTargetContainer; - from.model.location = { type: 'grid' }; - } else if (to.api.location.type === 'floating') { - from.model.renderContainer = - this.overlayRenderContainer; - from.model.dropTargetContainer = - this.rootDropTargetContainer; - from.model.location = { type: 'floating' }; - } - break; } } } // Place `source` next to `to`, in whichever gridview root `to` - // lives in. When `to` is inside a floating window this splits the - // window's nested layout rather than spawning a new window. + // lives in. When `to` is inside a floating / popout window this + // splits that window's nested layout rather than spawning a new one. if ( to.api.location.type === 'grid' || - to.api.location.type === 'floating' + to.api.location.type === 'floating' || + to.api.location.type === 'popout' ) { const destGridview = this.getGridviewForGroup(to); const referenceLocation = getGridLocation(to.element); @@ -3779,14 +4033,37 @@ export class DockviewComponent } /** - * Tag a group with the location type matching the gridview root it now - * lives in (main grid vs a floating window). Floating and grid groups - * share the main render/drop-target containers, so only `location` differs. + * Tag a group with the location and render / drop-target containers + * matching the gridview root it now lives in: the main grid, a floating + * window (shares the main containers), or a popout window (uses its own + * window-local containers). */ private setGroupLocationForRoot( group: DockviewGroupPanel, gridview: Gridview ): void { + const popout = this._popoutWindowService?.entries.find( + (entry) => entry.gridview === gridview + ); + + if (popout) { + if (group.model.renderContainer !== popout.overlayRenderContainer) { + group.model.renderContainer = popout.overlayRenderContainer; + } + group.model.dropTargetContainer = popout.dropTargetContainer; + group.model.location = { + type: 'popout', + getWindow: popout.getWindow, + popoutUrl: popout.popoutUrl, + }; + return; + } + + // grid / floating both render through the main containers + if (group.model.renderContainer !== this.overlayRenderContainer) { + group.model.renderContainer = this.overlayRenderContainer; + } + group.model.dropTargetContainer = this.rootDropTargetContainer; group.model.location = gridview === this.gridview ? { type: 'grid' } @@ -3795,28 +4072,35 @@ export class DockviewComponent /** * Resolve which gridview root currently owns a group: the main grid, or - * the nested gridview of the floating window it lives in. + * the nested gridview of the floating / popout window it lives in. */ getGridviewForGroup(group: DockviewGroupPanel): Gridview { - return ( - this._floatingGroupService?.findByGroup(group)?.gridview ?? - this.gridview + const floating = this._floatingGroupService?.findByGroup(group); + if (floating) { + return floating.gridview; + } + const popout = this._popoutWindowService?.entries.find((entry) => + entry.gridview.element.contains(group.element) ); + if (popout) { + return popout.gridview; + } + return this.gridview; } /** - * The groups that live within the same floating window as `group` - * (including `group` itself). Empty when `group` is not floating. + * The groups that live within the same floating / popout window as `group` + * (including `group` itself). Empty when `group` is in the main grid. */ - private floatingWindowMembers( + private nestedWindowMembers( group: DockviewGroupPanel ): DockviewGroupPanel[] { - const floating = this._floatingGroupService?.findByGroup(group); - if (!floating) { + const gridview = this.getGridviewForGroup(group); + if (gridview === this.gridview) { return []; } return this.groups.filter((candidate) => - floating.gridview.element.contains(candidate.element) + gridview.element.contains(candidate.element) ); } diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 2f023ad390..17bfd766b1 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -466,7 +466,9 @@ export class DockviewGroupPanelModel break; case 'popout': - applyZones(['center']); + // Popout windows host their own nested gridview, so an edge + // drop splits the window's layout just like the main grid. + applyZones(['top', 'bottom', 'left', 'right', 'center']); toggleClass(this.container, 'dv-groupview-popout', true); diff --git a/packages/dockview-core/src/dockview/popoutWindowService.ts b/packages/dockview-core/src/dockview/popoutWindowService.ts index ae25b36897..9bc51912a7 100644 --- a/packages/dockview-core/src/dockview/popoutWindowService.ts +++ b/packages/dockview-core/src/dockview/popoutWindowService.ts @@ -5,12 +5,28 @@ import { PopoutWindow } from '../popoutWindow'; import { DockviewGroupPanel } from './dockviewGroupPanel'; import { SerializedPopoutGroup } from './dockviewComponent'; import { GroupPanelViewState } from './dockviewGroupPanelModel'; +import { Gridview, ISerializedLeafNode } from '../gridview/gridview'; +import { OverlayRenderContainer } from '../overlay/overlayRenderContainer'; +import { DropTargetAnchorContainer } from '../dnd/dropTargetAnchorContainer'; import { defineModule } from './modules'; export interface PopoutGroupEntry { window: PopoutWindow; popoutGroup: DockviewGroupPanel; referenceGroup?: string; + /** + * The popout window hosts its own gridview so it can hold a nested + * splitview layout of groups. `popoutGroup` is the window's anchor group. + */ + gridview: Gridview; + /** + * Render / drop-target containers and window accessor for this popout, so + * groups relocated into the window can be wired to its own document. + */ + overlayRenderContainer: OverlayRenderContainer; + dropTargetContainer: DropTargetAnchorContainer; + getWindow: () => Window; + popoutUrl?: string; disposable: { dispose: () => DockviewGroupPanel | undefined }; } @@ -75,7 +91,13 @@ export class PopoutWindowService implements IPopoutWindowService { } findByGroup(group: DockviewGroupPanel): PopoutGroupEntry | undefined { - return this._entries.find((entry) => entry.popoutGroup === group); + // A popout window may host several groups in a nested gridview, so + // match by membership (DOM containment) rather than only the anchor. + return this._entries.find( + (entry) => + entry.popoutGroup === group || + entry.gridview.element.contains(group.element) + ); } findReferenceGroupId(group: DockviewGroupPanel): string | undefined { @@ -137,15 +159,37 @@ export class PopoutWindowService implements IPopoutWindowService { } serialize(): SerializedPopoutGroup[] { - return this._entries.map((entry) => ({ - data: entry.popoutGroup.toJSON() as GroupPanelViewState, - gridReferenceGroup: entry.referenceGroup, - position: entry.window.dimensions(), - url: + return this._entries.map((entry) => { + const grid = entry.gridview.serialize(); + const root = grid.root; + const url = entry.popoutGroup.api.location.type === 'popout' ? entry.popoutGroup.api.location.popoutUrl - : undefined, - })); + : undefined; + + const base = { + gridReferenceGroup: entry.referenceGroup, + position: entry.window.dimensions(), + url, + }; + + // Single-group window keeps the legacy `data` shape so layouts + // round-trip byte-stably and older readers keep working. + if ( + root.type === 'branch' && + root.data.length === 1 && + root.data[0].type === 'leaf' + ) { + return { + ...base, + data: ( + root.data[0] as ISerializedLeafNode + ).data, + }; + } + + return { ...base, grid }; + }); } disposeAll(): void { From 5f6ebe3a7c2477688f38617d49346ac34b5e2f98 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Fri, 29 May 2026 22:10:24 +0100 Subject: [PATCH 3/6] fix(dockview-core): robust popout restoration via fromJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the restore path against real-world popout edge cases surfaced by loading saved layouts: - when the browser blocks a popout window (window.open returns null — common when restoring on load), dock the group into the grid (or return its panels and dispose the empty group) instead of leaving an orphan; blocked content falls back into the grid rather than being lost - dispose 'grid'-location groups whose element isn't actually in the gridview (a popout-destined group swept up by a re-entrant fromJSON / clear) instead of throwing "Invalid grid element" Adds regression tests for blocked-popup restoration and rapid repeated fromJSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dockview/dockviewComponent.spec.ts | 82 +++++++++++++++++++ .../src/dockview/dockviewComponent.ts | 64 +++++++++++++-- 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 7f9a9fe08c..92be6658b8 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -8353,6 +8353,57 @@ describe('dockviewComponent', () => { jest.useRealTimers(); }); + test('restoring popouts while the browser blocks popups falls back to the grid and clears cleanly', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const container = document.createElement('div'); + const dockview = new DockviewComponent(container, { + createComponent(o) { + return new PanelContentPartTest(o.id, o.name); + }, + }); + dockview.layout(1000, 500); + dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + const p3 = dockview.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + await dockview.addPopoutGroup(p2.group); + await dockview.addPopoutGroup(p3.group); + const state = dockview.toJSON(); + + // the browser blocks the popouts when the layout is restored + (window as any).open = () => null; + + dockview.clear(); + dockview.fromJSON(state); + jest.advanceTimersByTime(500); + await dockview.popoutRestorationPromise; + + // blocked popout content is not lost — it falls back into the grid + expect(dockview.panels.map((p) => p.id).sort()).toEqual([ + 'p1', + 'p2', + 'p3', + ]); + expect( + dockview.groups.filter((g) => g.api.location.type === 'popout') + .length + ).toBe(0); + + // and clearing the restored layout doesn't throw on orphaned groups + expect(() => dockview.clear()).not.toThrow(); + expect(dockview.groups.length).toBe(0); + + jest.useRealTimers(); + }); + describe('when browsers block popups', () => { let container: HTMLDivElement; let dockview: DockviewComponent; @@ -8511,6 +8562,37 @@ describe('dockviewComponent', () => { } }); + test('rapid repeated fromJSON with popouts does not throw on orphaned groups', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const container = document.createElement('div'); + const dockview = new DockviewComponent(container, { + createComponent: (o) => new PanelContentPartTest(o.id, o.name), + }); + dockview.layout(1000, 500); + dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + const p3 = dockview.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + await dockview.addPopoutGroup(p2.group); + await dockview.addPopoutGroup(p3.group); + const state = JSON.parse(JSON.stringify(dockview.toJSON())); + + for (let i = 0; i < 6; i++) { + expect(() => dockview.fromJSON(state)).not.toThrow(); + jest.advanceTimersByTime(60); // let some popout timers fire + } + dockview.dispose(); + jest.useRealTimers(); + }); + test('dispose of dockview instance when popup is open', async () => { const container = document.createElement('div'); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index d81a5a1cf8..b5147d5e87 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -1037,14 +1037,38 @@ export class DockviewComponent popoutWindowDisposable.dispose(); this._onDidOpenPopoutWindowFail.fire(); - // if the popout window was blocked, we need to move the group back to the reference group - // and set it to visible - this.movingLock(() => - moveGroupWithoutDestroying({ - from: group, - to: referenceGroup, - }) - ); + // The popout window was blocked (e.g. by the browser's + // popup blocker — common when restoring popouts on load). + // Fall back gracefully so the group is valid and visible + // rather than an orphan that later crashes clear()/remove(). + if (group === referenceGroup) { + // No separate grid group to return to (e.g. restoring a + // popout straight from JSON) — dock this group into the + // main grid. + if (!this.gridview.element.contains(group.element)) { + this.movingLock(() => this.doAddGroup(group, [0])); + group.model.location = { type: 'grid' }; + } + } else { + // A fresh group was created for the popout — return its + // panels to the reference group and discard the now-empty + // popout group so it doesn't linger as an orphan. + this.movingLock(() => + moveGroupWithoutDestroying({ + from: group, + to: referenceGroup, + }) + ); + + if ( + group.model.size === 0 && + this._groups.has(group.id) + ) { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + } if (!referenceGroup.api.isVisible) { referenceGroup.api.setVisible(true); @@ -3073,6 +3097,30 @@ export class DockviewComponent throw new Error('dockview: failed to find popout group'); } + // A `grid`-location group whose element isn't actually in the gridview + // is an orphan — e.g. a popout-destined group created during fromJSON + // whose window hasn't opened yet, swept up by clear()/a re-entrant + // fromJSON. `gridview.remove()` would throw "Invalid grid element", so + // dispose it directly. + if (!this.gridview.element.contains(group.element)) { + if (!options?.skipDispose) { + const item = this._groups.get(group.id); + item?.disposable.dispose(); + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + + if (!options?.skipActive && this._activeGroup === group) { + const groups = Array.from(this._groups.values()); + this.doSetGroupAndPanelActive( + groups.length > 0 ? groups[0].value : undefined + ); + } + + return group; + } + const re = super.doRemoveGroup(group, options); if (!options?.skipActive) { From 7a531bdadf7faa90efb78c52f73a1f4581df2175 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Sat, 30 May 2026 22:33:45 +0100 Subject: [PATCH 4/6] fix(dockview-core): retarget floating title bar on anchor reassignment Three fixes from reviewing the nested floating/popout layout work: 1. The dedicated floating title bar (default floatingGroupDragHandle: 'titlebar') captured its anchor group permanently, so after the original anchor was dragged out of a surviving multi-group window the bar still redocked/activated the departed group. GroupDragSource.group now accepts a provider resolved lazily; FloatingTitleBar holds a mutable anchor with setGroup(); DockviewFloatingGroupPanel.setAnchorGroup retargets it; onWillDragGroup fires the live anchor. 2. Nested floating/popout gridview gap was not updated on runtime theme change; updateTheme now syncs .margin on every nested gridview. 3. Multi-group popout restore while popups are blocked orphaned the non-anchor members; the blocked fallback now docks every member into the main grid. Adds regression tests for (1) and (3). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dockview/dockviewComponent.spec.ts | 120 ++++++++++++++++++ .../components/titlebar/floatingTitleBar.ts | 24 +++- .../components/titlebar/groupDragSource.ts | 21 ++- .../src/dockview/dockviewComponent.ts | 50 +++++++- .../dockview/dockviewFloatingGroupPanel.ts | 18 +++ 5 files changed, 223 insertions(+), 10 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 92be6658b8..fe4aa9e64b 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -6183,6 +6183,126 @@ describe('dockviewComponent', () => { restored.getGridviewForGroup(floatingGroups[0]) ).not.toBe((restored as any).gridview); }); + + test('the floating title bar retargets when its anchor group leaves the window', () => { + const dockview = make(); // default dragHandle is 'titlebar' + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // build a 2-group floating window; panel2's group is the anchor + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + + const fg = dockview.floatingGroups[0]; + expect(fg.group).toBe(panel2.group); + + const titlebar = fg.overlay.element.querySelector( + '.dv-floating-titlebar' + ) as HTMLElement; + expect(titlebar).toBeTruthy(); + + // move the original anchor back to the grid; the window survives + // and promotes panel3's group as the new anchor + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + expect(fg.group).toBe(panel3.group); + + // dragging the title bar now targets the promoted anchor, not + // the departed original group + const groupDragEvents: GroupDragEvent[] = []; + dockview.onWillDragGroup((event) => + groupDragEvents.push(event) + ); + + // shift+drag is the title bar's redock gesture; jsdom's + // DragEvent ctor drops shiftKey, so set it explicitly + const event = new Event('dragstart') as DragEvent; + Object.defineProperty(event, 'shiftKey', { value: true }); + fireEvent(titlebar, event); + + expect(groupDragEvents.length).toBe(1); + expect(groupDragEvents[0].group).toBe(panel3.group); + + dockview.dispose(); + }); + + test('restoring a blocked multi-group popout docks every member into the grid', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + // split panel2's group into the popout -> 2-group popout window + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + const state = dockview.toJSON(); + expect(state.popoutGroups![0].grid).toBeDefined(); + + // the browser blocks the popout when the layout is restored + (window as any).open = () => null; + + dockview.clear(); + dockview.fromJSON(state); + jest.advanceTimersByTime(500); + await dockview.popoutRestorationPromise; + + // both groups fell back into the grid; nothing is lost or + // orphaned + expect(dockview.panels.map((p) => p.id).sort()).toEqual([ + 'panel_1', + 'panel_2', + ]); + expect( + dockview.groups.filter( + (g) => g.api.location.type === 'popout' + ).length + ).toBe(0); + // both members landed in the main grid (a hidden reference + // ghost group may also linger, as with single-group restore) + expect( + dockview.groups.filter( + (g) => g.api.location.type === 'grid' + ).length + ).toBeGreaterThanOrEqual(2); + + expect(() => dockview.clear()).not.toThrow(); + expect(dockview.groups.length).toBe(0); + + jest.useRealTimers(); + }); }); test('move a floating group of many tabs to a new fixed group', () => { diff --git a/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts b/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts index 0341e2ac56..5d7d222504 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts @@ -20,6 +20,7 @@ import { GroupDragSource } from './groupDragSource'; export class FloatingTitleBar extends CompositeDisposable { private readonly _element: HTMLElement; private readonly dragSource: GroupDragSource; + private _group: DockviewGroupPanel; private readonly _onDragStart = new Emitter(); readonly onDragStart = this._onDragStart.event; @@ -28,19 +29,35 @@ export class FloatingTitleBar extends CompositeDisposable { return this._element; } + /** The window's current anchor group — the one this bar drags/activates. */ + get group(): DockviewGroupPanel { + return this._group; + } + + /** + * Retarget the bar at a new anchor group. Called when the original anchor + * leaves a multi-group floating window and another member is promoted, so + * the bar keeps activating/redocking a group that actually lives here. + */ + setGroup(group: DockviewGroupPanel): void { + this._group = group; + } + constructor( private readonly accessor: DockviewComponent, - private readonly group: DockviewGroupPanel + group: DockviewGroupPanel ) { super(); + this._group = group; + this._element = document.createElement('div'); this._element.className = 'dv-floating-titlebar'; this.addDisposables( this._onDragStart, addDisposableListener(this._element, 'pointerdown', () => { - this.accessor.doSetGroupActive(this.group); + this.accessor.doSetGroupActive(this._group); }), // Shift+pointerdown marks the event so the overlay's // move-the-float drag doesn't fire alongside the HTML5 redock @@ -60,7 +77,8 @@ export class FloatingTitleBar extends CompositeDisposable { this.dragSource = new GroupDragSource({ element: this._element, accessor: this.accessor, - group: this.group, + // resolve lazily so the drag source follows anchor reassignment + group: () => this._group, }); this.addDisposables( diff --git a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts index ce05ccdd00..a7af6c56ff 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts @@ -29,7 +29,14 @@ const FLOATING_REDOCK_INITIATION_DELAY_MS = 500; export interface GroupDragSourceOptions { readonly element: HTMLElement; readonly accessor: DockviewComponent; - readonly group: DockviewGroupPanel; + /** + * The group this handle drags. Pass a function when the handle outlives the + * group it represents and can be retargeted — e.g. a floating window's + * dedicated title bar, whose anchor group is reassigned when the original + * anchor leaves a multi-group window. A fixed reference (the tab-bar void + * container, which lives inside its own group's DOM) is also accepted. + */ + readonly group: DockviewGroupPanel | (() => DockviewGroupPanel); /** * Whether this element is the floating window's move handle. Only the move * handle needs the floating disambiguation (shift for mouse / long-press @@ -54,7 +61,7 @@ export interface GroupDragSourceOptions { export class GroupDragSource extends CompositeDisposable { private readonly _element: HTMLElement; private readonly accessor: DockviewComponent; - private readonly group: DockviewGroupPanel; + private readonly groupAccessor: () => DockviewGroupPanel; private readonly html5DragSource: IDragSource; private readonly pointerDragSource: IDragSource; private readonly panelTransfer = @@ -65,12 +72,20 @@ export class GroupDragSource extends CompositeDisposable { private readonly isFloatingMoveHandle: () => boolean; + // Resolved lazily so a retargetable handle (the floating title bar) always + // drags the window's *current* anchor group, not the one captured here. + private get group(): DockviewGroupPanel { + return this.groupAccessor(); + } + constructor(options: GroupDragSourceOptions) { super(); this._element = options.element; this.accessor = options.accessor; - this.group = options.group; + const group = options.group; + this.groupAccessor = + typeof group === 'function' ? group : () => group; this.isFloatingMoveHandle = options.isFloatingMoveHandle ?? (() => true); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index b5147d5e87..d64adf5a8e 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -1041,6 +1041,35 @@ export class DockviewComponent // popup blocker — common when restoring popouts on load). // Fall back gracefully so the group is valid and visible // rather than an orphan that later crashes clear()/remove(). + if (options?.overridePopoutGridview) { + // Restoring a multi-group popout window: its nested + // gridview was built up-front but never attached to a + // window. Dock every member into the main grid so no + // group is lost, then discard the detached gridview. + const blockedGridview = options.overridePopoutGridview; + const members = this.groups.filter((candidate) => + blockedGridview.element.contains(candidate.element) + ); + for (const member of members) { + this.movingLock(() => { + blockedGridview.remove(member); + member.model.renderContainer = + this.overlayRenderContainer; + member.model.dropTargetContainer = + this.rootDropTargetContainer; + this.doAddGroup(member, [0]); + member.model.location = { type: 'grid' }; + }); + } + blockedGridview.dispose(); + + if (referenceGroup && !referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + + return false; + } + if (group === referenceGroup) { // No separate grid group to return to (e.g. restoring a // popout straight from JSON) — dock this group into the @@ -1714,13 +1743,17 @@ export class DockviewComponent if (titleBar) { // Tie the title bar's lifetime to the floating window and surface // its redock drag through the same public `onWillDragGroup` event - // the tab-bar handle uses. + // the tab-bar handle uses. Register it so anchor reassignment (when + // the original anchor leaves a multi-group window) retargets the + // bar at a group that still lives here. + floatingGroupPanel.setTitleBar(titleBar); floatingGroupPanel.addDisposables( titleBar, + Disposable.from(() => floatingGroupPanel.setTitleBar(undefined)), titleBar.onDragStart((event) => { this._onWillDragGroup.fire({ nativeEvent: event, - group: anchorGroup, + group: floatingGroupPanel.group, }); }) ); @@ -4173,9 +4206,18 @@ export class DockviewComponent // set on the shell from reaching the dockview subtree. this._shellThemeClassnames?.setClassNames(theme.className); - this.gridview.margin = theme.gap ?? 0; + const gap = theme.gap ?? 0; + this.gridview.margin = gap; + // Floating / popout windows host their own nested gridviews; keep their + // gap in sync with the main grid when the theme changes at runtime. + for (const floating of this.floatingGroups) { + floating.gridview.margin = gap; + } + for (const entry of this._popoutWindowService?.entries ?? []) { + entry.gridview.margin = gap; + } this._shellManager?.updateTheme( - theme.gap ?? 0, + gap, theme.edgeGroupCollapsedSize ?? 35 ); diff --git a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts index 9208e0ec93..ebb9492193 100644 --- a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts @@ -9,11 +9,20 @@ export interface IDockviewFloatingGroupPanel { position(bounds: Partial): void; } +/** + * The subset of the dedicated floating title bar this panel needs to keep in + * sync. Declared structurally to avoid a circular import on `FloatingTitleBar`. + */ +export interface IAnchorTrackingTitleBar { + setGroup(group: DockviewGroupPanel): void; +} + export class DockviewFloatingGroupPanel extends CompositeDisposable implements IDockviewFloatingGroupPanel { private _group: DockviewGroupPanel; + private _titleBar: IAnchorTrackingTitleBar | undefined; /** * The window's representative/anchor group. A floating window can host a @@ -24,8 +33,17 @@ export class DockviewFloatingGroupPanel return this._group; } + /** + * Register the dedicated title bar (if any) so anchor reassignment keeps + * its drag handle pointed at a group that still lives in this window. + */ + setTitleBar(titleBar: IAnchorTrackingTitleBar | undefined): void { + this._titleBar = titleBar; + } + setAnchorGroup(group: DockviewGroupPanel): void { this._group = group; + this._titleBar?.setGroup(group); } constructor( From 23ec348a236236da45bf2bb102eb12a230db258e Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Sat, 30 May 2026 23:07:33 +0100 Subject: [PATCH 5/6] style(dockview-core): apply prettier formatting Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/dockview/components/titlebar/groupDragSource.ts | 3 +-- packages/dockview-core/src/dockview/dockviewComponent.ts | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts index a7af6c56ff..b8ea663a72 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts @@ -84,8 +84,7 @@ export class GroupDragSource extends CompositeDisposable { this._element = options.element; this.accessor = options.accessor; const group = options.group; - this.groupAccessor = - typeof group === 'function' ? group : () => group; + this.groupAccessor = typeof group === 'function' ? group : () => group; this.isFloatingMoveHandle = options.isFloatingMoveHandle ?? (() => true); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index d64adf5a8e..6f4bb1553b 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -1749,7 +1749,9 @@ export class DockviewComponent floatingGroupPanel.setTitleBar(titleBar); floatingGroupPanel.addDisposables( titleBar, - Disposable.from(() => floatingGroupPanel.setTitleBar(undefined)), + Disposable.from(() => + floatingGroupPanel.setTitleBar(undefined) + ), titleBar.onDragStart((event) => { this._onWillDragGroup.fire({ nativeEvent: event, From d6ce3ab63565515183be55ae40b4019eb0cdf0d4 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:13:30 +0100 Subject: [PATCH 6/6] refactor(dockview-core): decompose floating/popout orchestration in dockviewComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating-nested-layout work concentrated a lot of logic in a few giant methods. Extract cohesive units (no behaviour change; full suite green): - addPopoutGroup (582→~378): handleBlockedPopout, disposePopoutWindow, redockGroupToMainGrid - detach cascade: detachFromNestedWindow unifies the float/popout multi-member detach + anchor-reassign previously duplicated in doRemoveGroup and moveGroup; add disposeGroupRecord and activateFallbackGroupIfRemoved - fromJSON (449→~249): deserializeEdgeGroups/FloatingWindows/PopoutWindows and shared deserializeNestedGridview - move popout ResizeObserver mechanics into PopoutWindowService.observeGridviewSize Grid-manipulating orchestration stays in the component; services keep their narrow host interface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/dockview/dockviewComponent.ts | 1200 +++++++++-------- .../src/dockview/popoutWindowService.ts | 65 + 2 files changed, 671 insertions(+), 594 deletions(-) diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 6f4bb1553b..59c044397f 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -1030,79 +1030,12 @@ export class DockviewComponent } if (popoutContainer === null) { - console.error( - 'dockview: failed to create popout. perhaps you need to allow pop-ups for this website' - ); - - popoutWindowDisposable.dispose(); - this._onDidOpenPopoutWindowFail.fire(); - - // The popout window was blocked (e.g. by the browser's - // popup blocker — common when restoring popouts on load). - // Fall back gracefully so the group is valid and visible - // rather than an orphan that later crashes clear()/remove(). - if (options?.overridePopoutGridview) { - // Restoring a multi-group popout window: its nested - // gridview was built up-front but never attached to a - // window. Dock every member into the main grid so no - // group is lost, then discard the detached gridview. - const blockedGridview = options.overridePopoutGridview; - const members = this.groups.filter((candidate) => - blockedGridview.element.contains(candidate.element) - ); - for (const member of members) { - this.movingLock(() => { - blockedGridview.remove(member); - member.model.renderContainer = - this.overlayRenderContainer; - member.model.dropTargetContainer = - this.rootDropTargetContainer; - this.doAddGroup(member, [0]); - member.model.location = { type: 'grid' }; - }); - } - blockedGridview.dispose(); - - if (referenceGroup && !referenceGroup.api.isVisible) { - referenceGroup.api.setVisible(true); - } - - return false; - } - - if (group === referenceGroup) { - // No separate grid group to return to (e.g. restoring a - // popout straight from JSON) — dock this group into the - // main grid. - if (!this.gridview.element.contains(group.element)) { - this.movingLock(() => this.doAddGroup(group, [0])); - group.model.location = { type: 'grid' }; - } - } else { - // A fresh group was created for the popout — return its - // panels to the reference group and discard the now-empty - // popout group so it doesn't linger as an orphan. - this.movingLock(() => - moveGroupWithoutDestroying({ - from: group, - to: referenceGroup, - }) - ); - - if ( - group.model.size === 0 && - this._groups.has(group.id) - ) { - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); - } - } - - if (!referenceGroup.api.isVisible) { - referenceGroup.api.setVisible(true); - } - + this.handleBlockedPopout({ + group, + referenceGroup, + options, + popoutWindowDisposable, + }); return false; } @@ -1257,58 +1190,15 @@ export class DockviewComponent this.doSetGroupAndPanelActive(group); - // The popout window's innerWidth/innerHeight are often 0/stale - // until it has painted, and the nested gridview lays its - // children out to the size passed to layout() (a plain group - // fills via CSS instead). To stop content rendering into a zero - // box until a manual resize — and to avoid the race a fixed - // number of animation frames had — observe the gridview element - // with a ResizeObserver created in the POPOUT window's OWN - // realm. A parent-realm observer fires unreliably across the - // window boundary; a same-realm one fires reliably, including - // the initial observation once the window is sized. - const PopoutResizeObserver = ( - _window.window as (Window & typeof globalThis) | undefined - )?.ResizeObserver; - if (PopoutResizeObserver) { - let lastWidth = -1; - let lastHeight = -1; - const relayout = () => { - const win = _window.window; - if (this.isDisposed || !win || win.closed) { - return; - } - const width = Math.round( - popoutGridview.element.clientWidth - ); - const height = Math.round( - popoutGridview.element.clientHeight - ); - if (width === lastWidth && height === lastHeight) { - return; - } - lastWidth = width; - lastHeight = height; - if (width > 0 && height > 0) { - popoutGridview.layout(width, height); - } - overlayRenderContainer.updateAllPositions(); - }; - const observer = new PopoutResizeObserver(() => { - // Defer out of the observer callback into the popout's - // own frame to size against the settled layout and to - // avoid resize-loop warnings. - const raf = _window.window?.requestAnimationFrame; - if (raf) { - raf.call(_window.window, relayout); - } else { - relayout(); - } - }); - observer.observe(popoutGridview.element); - popoutWindowDisposable.addDisposables({ - dispose: () => observer.disconnect(), - }); + const resizeObserverDisposable = service.observeGridviewSize( + _window, + popoutGridview, + overlayRenderContainer + ); + if (resizeObserverDisposable) { + popoutWindowDisposable.addDisposables( + resizeObserverDisposable + ); } popoutWindowDisposable.addDisposables( @@ -1322,7 +1212,10 @@ export class DockviewComponent }) ); - let returnedGroup: DockviewGroupPanel | undefined; + // Holder so the close teardown (extracted below) can publish + // the group that was returned to the main grid back to the + // entry's `dispose()` contract. + const closeResult: { returnedGroup?: DockviewGroupPanel } = {}; const isValidReferenceGroup = isGroupAddedToDom && @@ -1343,7 +1236,7 @@ export class DockviewComponent disposable: { dispose: () => { popoutWindowDisposable.dispose(); - return returnedGroup; + return closeResult.returnedGroup; }, }, }; @@ -1375,124 +1268,239 @@ export class DockviewComponent ); }), overlayRenderContainer, - Disposable.from(() => { - if (this.isDisposed) { - // cleanup may run after instance is disposed; just - // tear down the nested gridview. - disposePopoutGridview(); - return; - } + Disposable.from(() => + this.disposePopoutWindow({ + group, + referenceGroup, + popoutGridview, + isGroupAddedToDom, + floatingBox, + disposePopoutGridview, + closeResult, + }) + ) + ); - // A popout window may host several groups. On a genuine - // window close (the entry is still tracked), relocate - // every non-anchor member back to the main grid before - // the anchor logic below handles `group`. Explicit - // removal paths remove the entry first, so this is - // skipped for them. - if (service.findByGroup(group)) { - const extraMembers = this.groups.filter( - (candidate) => - candidate !== group && - popoutGridview.element.contains( - candidate.element - ) - ); + service.add(value); - for (const member of extraMembers) { - this.movingLock(() => { - this.doRemoveGroup(member, { - skipDispose: true, - skipActive: true, - skipPopoutReturn: true, - }); - member.model.renderContainer = - this.overlayRenderContainer; - member.model.dropTargetContainer = - this.rootDropTargetContainer; - member.model.location = { type: 'grid' }; - this.doAddGroup(member, [0]); - }); - } - } + return true; + }) + .catch((err) => { + console.error('dockview: failed to create popout.', err); + return false; + }); + } - if ( - isGroupAddedToDom && - this.getPanel(referenceGroup.id) - ) { - this.movingLock(() => - moveGroupWithoutDestroying({ - from: group, - to: referenceGroup, - }) - ); + /** + * The popout window was blocked (e.g. by the browser's popup blocker — + * common when restoring popouts on load). Fall back gracefully so the + * group(s) end up valid and visible in the main grid rather than as + * orphans that later crash clear()/remove(). + */ + private handleBlockedPopout(params: { + group: DockviewGroupPanel; + referenceGroup: DockviewGroupPanel; + options?: DockviewPopoutGroupOptionsInternal; + popoutWindowDisposable: CompositeDisposable; + }): void { + const { group, referenceGroup, options, popoutWindowDisposable } = + params; - if (!referenceGroup.api.isVisible) { - referenceGroup.api.setVisible(true); - } + console.error( + 'dockview: failed to create popout. perhaps you need to allow pop-ups for this website' + ); - if (this.getPanel(group.id)) { - this.doRemoveGroup(group, { - skipPopoutAssociated: true, - }); - } - } else if (this.getPanel(group.id)) { - group.model.renderContainer = - this.overlayRenderContainer; - group.model.dropTargetContainer = - this.rootDropTargetContainer; - returnedGroup = group; - - const alreadyRemoved = !service.findByGroup(group); - - if (alreadyRemoved) { - /** - * If this popout group was explicitly removed then we shouldn't run the additional - * steps. To tell if the running of this disposable is the result of this popout group - * being explicitly removed we can check if this popout group is still tracked by - * the popout window service. - */ - disposePopoutGridview(); - return; - } + popoutWindowDisposable.dispose(); + this._onDidOpenPopoutWindowFail.fire(); + + if (options?.overridePopoutGridview) { + // Restoring a multi-group popout window: its nested gridview was + // built up-front but never attached to a window. Dock every member + // into the main grid so no group is lost, then discard the + // detached gridview. + const blockedGridview = options.overridePopoutGridview; + const members = this.groups.filter((candidate) => + blockedGridview.element.contains(candidate.element) + ); + for (const member of members) { + this.movingLock(() => { + blockedGridview.remove(member); + this.redockGroupToMainGrid(member); + }); + } + blockedGridview.dispose(); - if (floatingBox) { - this.addFloatingGroup(group, { - height: floatingBox.height, - width: floatingBox.width, - position: floatingBox, - }); - } else { - this.doRemoveGroup(group, { - skipDispose: true, - skipActive: true, - skipPopoutReturn: true, - }); + if (referenceGroup && !referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } - group.model.location = { type: 'grid' }; + return; + } - this.movingLock(() => { - // suppress group add events since the group already exists - this.doAddGroup(group, [0]); - }); - } - this.doSetGroupAndPanelActive(group); - } + if (group === referenceGroup) { + // No separate grid group to return to (e.g. restoring a popout + // straight from JSON) — dock this group into the main grid. + if (!this.gridview.element.contains(group.element)) { + this.movingLock(() => this.doAddGroup(group, [0])); + group.model.location = { type: 'grid' }; + } + } else { + // A fresh group was created for the popout — return its panels to + // the reference group and discard the now-empty popout group so it + // doesn't linger as an orphan. + this.movingLock(() => + moveGroupWithoutDestroying({ + from: group, + to: referenceGroup, + }) + ); - // All members have been relocated out; tear down the - // window's nested gridview (does not dispose the leaf - // views — their lifecycle stays with `_groups`). - disposePopoutGridview(); - }) - ); + if (group.model.size === 0 && this._groups.has(group.id)) { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + } - service.add(value); + if (!referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + } - return true; - }) - .catch((err) => { - console.error('dockview: failed to create popout.', err); - return false; - }); + /** + * Wire a group that has been displaced from a floating / popout window back + * to the main grid's render & drop-target containers and dock it at the + * root. The caller is responsible for first detaching it from its old + * gridview — the detach strategy differs between the window-teardown path + * (`doRemoveGroup`) and the blocked-window path (`gridview.remove`). + */ + private redockGroupToMainGrid(group: DockviewGroupPanel): void { + group.model.renderContainer = this.overlayRenderContainer; + group.model.dropTargetContainer = this.rootDropTargetContainer; + group.model.location = { type: 'grid' }; + this.doAddGroup(group, [0]); + } + + /** + * Teardown for a popout window's `popoutWindowDisposable`. Runs when the + * window closes (by user, by `removeGroup`, or by component disposal): + * relocates every member group back to the main grid (or to a floating + * window when the anchor came from one), then disposes the nested gridview. + * `closeResult.returnedGroup` is read by the entry's `dispose()` contract. + */ + private disposePopoutWindow(params: { + group: DockviewGroupPanel; + referenceGroup: DockviewGroupPanel; + popoutGridview: Gridview; + isGroupAddedToDom: boolean; + floatingBox: AnchoredBox | undefined; + disposePopoutGridview: () => void; + closeResult: { returnedGroup?: DockviewGroupPanel }; + }): void { + const { + group, + referenceGroup, + popoutGridview, + isGroupAddedToDom, + floatingBox, + disposePopoutGridview, + closeResult, + } = params; + + if (this.isDisposed) { + // cleanup may run after instance is disposed; just tear down the + // nested gridview. + disposePopoutGridview(); + return; + } + + // A popout window may host several groups. On a genuine window close + // (the entry is still tracked), relocate every non-anchor member back + // to the main grid before the anchor logic below handles `group`. + // Explicit removal paths remove the entry first, so this is skipped for + // them. + if (this._popoutWindowService?.findByGroup(group)) { + const extraMembers = this.groups.filter( + (candidate) => + candidate !== group && + popoutGridview.element.contains(candidate.element) + ); + + for (const member of extraMembers) { + this.movingLock(() => { + this.doRemoveGroup(member, { + skipDispose: true, + skipActive: true, + skipPopoutReturn: true, + }); + this.redockGroupToMainGrid(member); + }); + } + } + + if (isGroupAddedToDom && this.getPanel(referenceGroup.id)) { + this.movingLock(() => + moveGroupWithoutDestroying({ + from: group, + to: referenceGroup, + }) + ); + + if (!referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + + if (this.getPanel(group.id)) { + this.doRemoveGroup(group, { + skipPopoutAssociated: true, + }); + } + } else if (this.getPanel(group.id)) { + group.model.renderContainer = this.overlayRenderContainer; + group.model.dropTargetContainer = this.rootDropTargetContainer; + closeResult.returnedGroup = group; + + const alreadyRemoved = + !this._popoutWindowService?.findByGroup(group); + + if (alreadyRemoved) { + /** + * If this popout group was explicitly removed then we shouldn't run the additional + * steps. To tell if the running of this disposable is the result of this popout group + * being explicitly removed we can check if this popout group is still tracked by + * the popout window service. + */ + disposePopoutGridview(); + return; + } + + if (floatingBox) { + this.addFloatingGroup(group, { + height: floatingBox.height, + width: floatingBox.width, + position: floatingBox, + }); + } else { + this.doRemoveGroup(group, { + skipDispose: true, + skipActive: true, + skipPopoutReturn: true, + }); + + group.model.location = { type: 'grid' }; + + this.movingLock(() => { + // suppress group add events since the group already exists + this.doAddGroup(group, [0]); + }); + } + this.doSetGroupAndPanelActive(group); + } + + // All members have been relocated out; tear down the window's nested + // gridview (does not dispose the leaf views — their lifecycle stays + // with `_groups`). + disposePopoutGridview(); } addFloatingGroup( @@ -2318,225 +2326,20 @@ export class DockviewComponent this._layoutFromShell(width, height); - const edgeService = data.edgeGroups - ? assertModule( - this._edgeGroupService, - 'EdgeGroup', - 'fromJSON edge restoration' - ) - : this._edgeGroupService; - - if (data.edgeGroups && edgeService) { - // Auto-create edge groups for positions in the serialized state - // that don't already have a group registered (e.g. when fromJSON - // is called before the user has called addEdgeGroup). - for (const _position of [ - 'top', - 'bottom', - 'left', - 'right', - ] as EdgeGroupPosition[]) { - const fixedData = data.edgeGroups[_position]; - if (fixedData && !edgeService.has(_position)) { - const groupState = fixedData.group as - | GroupPanelViewState - | undefined; - const id = groupState?.id ?? `${_position}-group`; - this.addEdgeGroup(_position, { id }); - } - } - - // Restore panel contents of edge groups - for (const [position, edgeGroup] of edgeService.entries()) { - const edgeData = data.edgeGroups[position]; - const groupState = edgeData?.group as - | GroupPanelViewState - | undefined; - if (groupState) { - const { views, activeView } = groupState; - const createdPanels: IDockviewPanel[] = []; - - for (const panelId of views) { - if (panels[panelId]) { - const panel = this._deserializer.fromJSON( - panels[panelId], - edgeGroup - ); - createdPanels.push(panel); - } - } - - for (let i = 0; i < createdPanels.length; i++) { - const panel = createdPanels[i]; - const isActive = activeView === panel.id; - edgeGroup.model.openPanel(panel, { - skipSetActive: !isActive, - skipSetGroupActive: true, - }); - } - - // Restore tab groups before activating a fallback panel - if ( - groupState.tabGroups && - groupState.tabGroups.length > 0 - ) { - edgeGroup.model.restoreTabGroups( - groupState.tabGroups - ); - } - - if ( - !edgeGroup.activePanel && - edgeGroup.panels.length > 0 - ) { - edgeGroup.model.openPanel( - edgeGroup.panels[edgeGroup.panels.length - 1], - { skipSetGroupActive: true } - ); - } - } - } - - this._shellManager!.fromJSON(data.edgeGroups); + if (data.edgeGroups) { + this.deserializeEdgeGroups(data.edgeGroups, panels); } - const serializedFloatingGroups = data.floatingGroups ?? []; - - for (const serializedFloatingGroup of serializedFloatingGroups) { - const { data, grid, position } = serializedFloatingGroup; - - if (grid) { - // Multi-group window: rebuild the window's nested gridview - // from its serialized tree. - const floatingGridview = this.createNestedGridview( - grid.orientation - ); - const members: DockviewGroupPanel[] = []; - floatingGridview.deserialize(grid, { - fromJSON: ( - node: ISerializedLeafNode - ) => { - const group = createGroupFromSerializedState( - node.data - ); - members.push(group); - return group; - }, - }); - - if (members.length === 0) { - continue; - } - - this.mountFloatingWindow( - floatingGridview, - members[0], - members, - position, - { inDragMode: false } - ); - } else if (data) { - const group = createGroupFromSerializedState(data); - - this.addFloatingGroup(group, { - position: position, - width: position.width, - height: position.height, - skipRemoveGroup: true, - inDragMode: false, - }); - } - } + this.deserializeFloatingWindows( + data.floatingGroups ?? [], + createGroupFromSerializedState + ); - const serializedPopoutGroups = data.popoutGroups ?? []; - - const popoutService = - serializedPopoutGroups.length > 0 - ? assertModule( - this._popoutWindowService, - 'PopoutWindow', - 'fromJSON popout restoration' - ) - : this._popoutWindowService; - - // Queue popup group creation with delays to avoid browser blocking - const popoutPromises = popoutService - ? serializedPopoutGroups.map((serializedPopoutGroup, index) => { - const { data, grid, position, gridReferenceGroup, url } = - serializedPopoutGroup; - - // Multi-group popout windows rebuild their nested gridview - // from the serialized tree; single-group windows use the - // legacy single-group path. - let overridePopoutGridview: Gridview | undefined; - const members: DockviewGroupPanel[] = []; - - if (grid) { - overridePopoutGridview = this.createNestedGridview( - grid.orientation - ); - overridePopoutGridview.deserialize(grid, { - fromJSON: ( - node: ISerializedLeafNode - ) => { - const member = createGroupFromSerializedState( - node.data - ); - members.push(member); - return member; - }, - }); - } - - const group = grid - ? members[0] - : createGroupFromSerializedState(data!); - - return popoutService.scheduleRestoration( - index * DESERIALIZATION_POPOUT_DELAY_MS, - () => { - this.addPopoutGroup(group, { - position: position ?? undefined, - overridePopoutGroup: gridReferenceGroup - ? group - : undefined, - overridePopoutGridview, - referenceGroup: gridReferenceGroup - ? this.getPanel(gridReferenceGroup) - : undefined, - popoutUrl: url, - }); - }, - () => { - // The group was registered in _groups synchronously - // but the timer that would parent it into the popout - // window never ran. Dispose the orphan here so the - // next clear() doesn't trip over an unparented - // element. See issue #1304. - for (const orphan of members.length > 0 - ? members - : [group]) { - if ( - !this.isDisposed && - this._groups.has(orphan.id) && - orphan.element.parentElement === null - ) { - for (const panel of [...orphan.panels]) { - this.removePanel(panel, { - removeEmptyGroup: false, - }); - } - orphan.dispose(); - this._groups.delete(orphan.id); - this._onDidRemoveGroup.fire(orphan); - } - } - } - ); - }) - : []; - - popoutService?.finishRestoration(popoutPromises); + const popoutPromises = this.deserializePopoutWindows( + data.popoutGroups ?? [], + createGroupFromSerializedState + ); + this._popoutWindowService?.finishRestoration(popoutPromises); this._floatingGroupService?.constrainBounds(); @@ -2593,6 +2396,222 @@ export class DockviewComponent this._onDidLayoutFromJSON.fire(); } + /** + * Rebuild a floating / popout window's nested gridview from its serialized + * tree, collecting the member groups (in deserialization order) so the + * caller can mount or restore the window. + */ + private deserializeNestedGridview( + grid: SerializedGridview, + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): { gridview: Gridview; members: DockviewGroupPanel[] } { + const gridview = this.createNestedGridview(grid.orientation); + const members: DockviewGroupPanel[] = []; + gridview.deserialize(grid, { + fromJSON: (node: ISerializedLeafNode) => { + const group = createGroup(node.data); + members.push(group); + return group; + }, + }); + return { gridview, members }; + } + + private deserializeEdgeGroups( + edgeGroups: SerializedEdgeGroups, + panels: Record + ): void { + const edgeService = assertModule( + this._edgeGroupService, + 'EdgeGroup', + 'fromJSON edge restoration' + ); + if (!edgeService) { + return; + } + + // Auto-create edge groups for positions in the serialized state that + // don't already have a group registered (e.g. when fromJSON is called + // before the user has called addEdgeGroup). + for (const _position of [ + 'top', + 'bottom', + 'left', + 'right', + ] as EdgeGroupPosition[]) { + const fixedData = edgeGroups[_position]; + if (fixedData && !edgeService.has(_position)) { + const groupState = fixedData.group as + | GroupPanelViewState + | undefined; + const id = groupState?.id ?? `${_position}-group`; + this.addEdgeGroup(_position, { id }); + } + } + + // Restore panel contents of edge groups + for (const [position, edgeGroup] of edgeService.entries()) { + const edgeData = edgeGroups[position]; + const groupState = edgeData?.group as + | GroupPanelViewState + | undefined; + if (groupState) { + const { views, activeView } = groupState; + const createdPanels: IDockviewPanel[] = []; + + for (const panelId of views) { + if (panels[panelId]) { + const panel = this._deserializer.fromJSON( + panels[panelId], + edgeGroup + ); + createdPanels.push(panel); + } + } + + for (let i = 0; i < createdPanels.length; i++) { + const panel = createdPanels[i]; + const isActive = activeView === panel.id; + edgeGroup.model.openPanel(panel, { + skipSetActive: !isActive, + skipSetGroupActive: true, + }); + } + + // Restore tab groups before activating a fallback panel + if (groupState.tabGroups && groupState.tabGroups.length > 0) { + edgeGroup.model.restoreTabGroups(groupState.tabGroups); + } + + if (!edgeGroup.activePanel && edgeGroup.panels.length > 0) { + edgeGroup.model.openPanel( + edgeGroup.panels[edgeGroup.panels.length - 1], + { skipSetGroupActive: true } + ); + } + } + } + + this._shellManager!.fromJSON(edgeGroups); + } + + private deserializeFloatingWindows( + serialized: SerializedFloatingGroup[], + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): void { + for (const serializedFloatingGroup of serialized) { + const { data, grid, position } = serializedFloatingGroup; + + if (grid) { + // Multi-group window: rebuild the window's nested gridview from + // its serialized tree. + const { gridview: floatingGridview, members } = + this.deserializeNestedGridview(grid, createGroup); + + if (members.length === 0) { + continue; + } + + this.mountFloatingWindow( + floatingGridview, + members[0], + members, + position, + { inDragMode: false } + ); + } else if (data) { + const group = createGroup(data); + + this.addFloatingGroup(group, { + position: position, + width: position.width, + height: position.height, + skipRemoveGroup: true, + inDragMode: false, + }); + } + } + } + + private deserializePopoutWindows( + serialized: SerializedPopoutGroup[], + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): Promise[] { + const popoutService = + serialized.length > 0 + ? assertModule( + this._popoutWindowService, + 'PopoutWindow', + 'fromJSON popout restoration' + ) + : this._popoutWindowService; + + if (!popoutService) { + return []; + } + + // Queue popup group creation with delays to avoid browser blocking + return serialized.map((serializedPopoutGroup, index) => { + const { data, grid, position, gridReferenceGroup, url } = + serializedPopoutGroup; + + // Multi-group popout windows rebuild their nested gridview from the + // serialized tree; single-group windows use the legacy single-group + // path. + let overridePopoutGridview: Gridview | undefined; + let members: DockviewGroupPanel[] = []; + + if (grid) { + const built = this.deserializeNestedGridview(grid, createGroup); + overridePopoutGridview = built.gridview; + members = built.members; + } + + const group = grid ? members[0] : createGroup(data!); + + return popoutService.scheduleRestoration( + index * DESERIALIZATION_POPOUT_DELAY_MS, + () => { + this.addPopoutGroup(group, { + position: position ?? undefined, + overridePopoutGroup: gridReferenceGroup + ? group + : undefined, + overridePopoutGridview, + referenceGroup: gridReferenceGroup + ? this.getPanel(gridReferenceGroup) + : undefined, + popoutUrl: url, + }); + }, + () => { + // The group was registered in _groups synchronously but the + // timer that would parent it into the popout window never + // ran. Dispose the orphan here so the next clear() doesn't + // trip over an unparented element. See issue #1304. + for (const orphan of members.length > 0 + ? members + : [group]) { + if ( + !this.isDisposed && + this._groups.has(orphan.id) && + orphan.element.parentElement === null + ) { + for (const panel of [...orphan.panels]) { + this.removePanel(panel, { + removeEmptyGroup: false, + }); + } + orphan.dispose(); + this._groups.delete(orphan.id); + this._onDidRemoveGroup.fire(orphan); + } + } + } + ); + }); + } + clear(): void { const groups = Array.from(this._groups.values()).map((_) => _.value); @@ -2962,6 +2981,74 @@ export class DockviewComponent this.doRemoveGroup(group, options); } + /** + * Detach a single group from the nested gridview of its floating / popout + * window, keeping the window and its remaining members alive, and reassign + * the window's anchor if the detached group was it. + * + * @returns `true` if the group was detached from a multi-member window; + * `false` if `group` is not in a nested window, or is the window's only + * member — in which case the caller is responsible for disposing the whole + * window. + */ + private detachFromNestedWindow(group: DockviewGroupPanel): boolean { + const floating = this._floatingGroupService?.findByGroup(group); + if (floating) { + const members = this.nestedWindowMembers(group); + if (members.length <= 1) { + return false; + } + floating.gridview.remove(group); + if (floating.group === group) { + // The anchor left; promote a remaining member. + floating.setAnchorGroup(members.find((m) => m !== group)!); + } + return true; + } + + const popout = this._popoutWindowService?.findByGroup(group); + if (popout) { + const members = this.nestedWindowMembers(group); + if (members.length <= 1) { + return false; + } + popout.gridview.remove(group); + if (popout.popoutGroup === group) { + // The anchor left; promote a remaining member. + popout.popoutGroup = members.find((m) => m !== group)!; + } + return true; + } + + return false; + } + + /** + * Dispose a group and forget it: remove it from `_groups` and fire the + * removed event. + */ + private disposeGroupRecord(group: DockviewGroupPanel): void { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + + /** + * When `removed` was the active group, fall the active selection back to + * the first remaining group (or clear it when none remain). + */ + private activateFallbackGroupIfRemoved( + removed: DockviewGroupPanel, + skipActive?: boolean + ): void { + if (!skipActive && this._activeGroup === removed) { + const groups = Array.from(this._groups.values()); + this.doSetGroupAndPanelActive( + groups.length > 0 ? groups[0].value : undefined + ); + } + } + protected override doRemoveGroup( group: DockviewGroupPanel, options?: @@ -2995,141 +3082,87 @@ export class DockviewComponent const floatingGroup = this._floatingGroupService?.findByGroup(group); - if (floatingGroup) { - const members = this.nestedWindowMembers(group); - - if (members.length > 1) { - // The floating window hosts other groups — remove just this - // group's view from the window's nested gridview and keep - // the window alive. - floatingGroup.gridview.remove(group); - - if (floatingGroup.group === group) { - // The anchor left; promote a remaining member. - floatingGroup.setAnchorGroup( - members.find((m) => m !== group)! - ); - } - - if (!options?.skipDispose) { - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); - } else { - // Relocation: reset location so the destination root - // can re-tag it. - group.model.location = { type: 'grid' }; - } - - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } - - return group; - } + if (!floatingGroup) { + throw new Error('dockview: failed to find floating group'); + } + if (this.detachFromNestedWindow(group)) { + // The floating window hosts other groups and stays alive — + // finalize just this group. if (!options?.skipDispose) { - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); - } - - // floatingGroup.dispose() removes itself from the service array - floatingGroup.dispose(); - - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); - - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); + this.disposeGroupRecord(group); + } else { + // Relocation: reset location so the destination root can + // re-tag it. + group.model.location = { type: 'grid' }; } + this.activateFallbackGroupIfRemoved(group, options?.skipActive); return group; } - throw new Error('dockview: failed to find floating group'); + // Last group leaving — dispose the whole floating window. + if (!options?.skipDispose) { + this.disposeGroupRecord(group); + } + + // floatingGroup.dispose() removes itself from the service array + floatingGroup.dispose(); + + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return group; } if (group.api.location.type === 'popout') { const selectedGroup = this._popoutWindowService?.findByGroup(group); - if (selectedGroup) { - const members = this.nestedWindowMembers(group); - - if (members.length > 1) { - // The popout window hosts other groups — remove just this - // group's view from the window's nested gridview and keep - // the window alive. - selectedGroup.gridview.remove(group); - - if (selectedGroup.popoutGroup === group) { - // The anchor left; promote a remaining member. - selectedGroup.popoutGroup = members.find( - (m) => m !== group - )!; - } - - if (!options?.skipDispose) { - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); - } else { - // Relocation: reset location so the destination root - // can re-tag it. - group.model.location = { type: 'grid' }; - } - - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } - - return group; - } + if (!selectedGroup) { + throw new Error('dockview: failed to find popout group'); + } + if (this.detachFromNestedWindow(group)) { + // The popout window hosts other groups and stays alive — + // finalize just this group. if (!options?.skipDispose) { - if (!options?.skipPopoutAssociated) { - const refGroup = selectedGroup.referenceGroup - ? this.getPanel(selectedGroup.referenceGroup) - : undefined; - if (refGroup && refGroup.panels.length === 0) { - this.removeGroup(refGroup); - } - } + this.disposeGroupRecord(group); + } else { + // Relocation: reset location so the destination root can + // re-tag it. + group.model.location = { type: 'grid' }; + } - selectedGroup.popoutGroup.dispose(); + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return group; + } - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); + // Last group leaving — tear the whole popout window down. + if (!options?.skipDispose) { + if (!options?.skipPopoutAssociated) { + const refGroup = selectedGroup.referenceGroup + ? this.getPanel(selectedGroup.referenceGroup) + : undefined; + if (refGroup && refGroup.panels.length === 0) { + this.removeGroup(refGroup); + } } - this._popoutWindowService?.remove(selectedGroup); - - const removedGroup = selectedGroup.disposable.dispose(); + selectedGroup.popoutGroup.dispose(); - if (!options?.skipPopoutReturn && removedGroup) { - this.doAddGroup(removedGroup, [0]); - this.doSetGroupAndPanelActive(removedGroup); - } + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); + this._popoutWindowService?.remove(selectedGroup); - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } + const removedGroup = selectedGroup.disposable.dispose(); - return selectedGroup.popoutGroup; + if (!options?.skipPopoutReturn && removedGroup) { + this.doAddGroup(removedGroup, [0]); + this.doSetGroupAndPanelActive(removedGroup); } - throw new Error('dockview: failed to find popout group'); + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return selectedGroup.popoutGroup; } // A `grid`-location group whose element isn't actually in the gridview @@ -3141,17 +3174,10 @@ export class DockviewComponent if (!options?.skipDispose) { const item = this._groups.get(group.id); item?.disposable.dispose(); - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); + this.disposeGroupRecord(group); } - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } + this.activateFallbackGroupIfRemoved(group, options?.skipActive); return group; } @@ -3787,18 +3813,11 @@ export class DockviewComponent ); } - const members = this.nestedWindowMembers(from); - if (members.length > 1) { - // Detach just this group from the floating window's - // nested gridview; keep the window (and its other - // groups) alive. - selectedFloatingGroup.gridview.remove(from); - if (selectedFloatingGroup.group === from) { - selectedFloatingGroup.setAnchorGroup( - members.find((m) => m !== from)! - ); - } - } else { + // Detach just this group from the floating window's + // nested gridview, keeping the window (and its other + // groups) alive. If it was the only member, dispose the + // whole window. + if (!this.detachFromNestedWindow(from)) { selectedFloatingGroup.dispose(); } break; @@ -3812,18 +3831,11 @@ export class DockviewComponent ); } - const members = this.nestedWindowMembers(from); - if (members.length > 1) { - // Detach just this group from the popout window's - // nested gridview; keep the window + its other - // groups alive. Destination containers/location are - // applied by the placement block below. - selectedPopoutGroup.gridview.remove(from); - if (selectedPopoutGroup.popoutGroup === from) { - selectedPopoutGroup.popoutGroup = members.find( - (m) => m !== from - )!; - } + // Detach just this group from the popout window's + // nested gridview, keeping the window + its other groups + // alive. Destination containers/location are applied by + // the placement block below. + if (this.detachFromNestedWindow(from)) { break; } diff --git a/packages/dockview-core/src/dockview/popoutWindowService.ts b/packages/dockview-core/src/dockview/popoutWindowService.ts index 9bc51912a7..53fe812c10 100644 --- a/packages/dockview-core/src/dockview/popoutWindowService.ts +++ b/packages/dockview-core/src/dockview/popoutWindowService.ts @@ -46,6 +46,12 @@ export interface IPopoutWindowService extends IDisposable { findByGroup(group: DockviewGroupPanel): PopoutGroupEntry | undefined; findReferenceGroupId(group: DockviewGroupPanel): string | undefined; + observeGridviewSize( + popoutWindow: PopoutWindow, + gridview: Gridview, + overlayRenderContainer: OverlayRenderContainer + ): IDisposable | undefined; + getPopupService(groupId: string): PopupService | undefined; setPopupService(groupId: string, service: PopupService): void; deletePopupService(groupId: string): void; @@ -105,6 +111,65 @@ export class PopoutWindowService implements IPopoutWindowService { ?.referenceGroup; } + /** + * The popout window's innerWidth/innerHeight are often 0/stale until it has + * painted, and the nested gridview lays its children out to the size passed + * to layout() (a plain group fills via CSS instead). To stop content + * rendering into a zero box until a manual resize — and to avoid the race a + * fixed number of animation frames had — observe the gridview element with + * a ResizeObserver created in the POPOUT window's OWN realm. A parent-realm + * observer fires unreliably across the window boundary; a same-realm one + * fires reliably, including the initial observation once the window is + * sized. + * + * @returns a disposable that disconnects the observer, or `undefined` when + * the popout realm has no ResizeObserver (e.g. jsdom). + */ + observeGridviewSize( + popoutWindow: PopoutWindow, + gridview: Gridview, + overlayRenderContainer: OverlayRenderContainer + ): IDisposable | undefined { + const PopoutResizeObserver = ( + popoutWindow.window as (Window & typeof globalThis) | undefined + )?.ResizeObserver; + if (!PopoutResizeObserver) { + return undefined; + } + + let lastWidth = -1; + let lastHeight = -1; + const relayout = () => { + const win = popoutWindow.window; + if (this._host.isDisposed || !win || win.closed) { + return; + } + const width = Math.round(gridview.element.clientWidth); + const height = Math.round(gridview.element.clientHeight); + if (width === lastWidth && height === lastHeight) { + return; + } + lastWidth = width; + lastHeight = height; + if (width > 0 && height > 0) { + gridview.layout(width, height); + } + overlayRenderContainer.updateAllPositions(); + }; + const observer = new PopoutResizeObserver(() => { + // Defer out of the observer callback into the popout's own frame to + // size against the settled layout and to avoid resize-loop warnings. + const raf = popoutWindow.window?.requestAnimationFrame; + if (raf) { + raf.call(popoutWindow.window, relayout); + } else { + relayout(); + } + }); + observer.observe(gridview.element); + return { dispose: () => observer.disconnect() }; + } + getPopupService(groupId: string): PopupService | undefined { return this._popupServices.get(groupId); }