From 44a854d3381a2085c51db08018711c73b42a7074 Mon Sep 17 00:00:00 2001 From: Abhay Nathwani Date: Fri, 22 May 2026 01:59:02 +0530 Subject: [PATCH] feat(dockview-core): pinnable overlay panels for edge groups (#1283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pinnable/auto-hide overlay mode for edge groups, matching the Visual Studio tool-window pattern (closes #1283, relates to #664). Pinned (default): edge groups push the layout when expanded — existing behaviour is unchanged. Unpinned: the group collapses to its tab strip only in the layout. Clicking a tab opens the panel as an absolutely-positioned overlay over the content area; clicking outside dismisses it. - EdgeGroupOptions.pinned?: boolean — opt-in at construction time - DockviewGroupPanelApi: setPinned() / isPinned() / onDidPinnedChange - ShellManager: setEdgeGroupPinned / isEdgeGroupPinned with full overlay lifecycle (_showOverlay / _hideOverlay) - Overlay removes dv-edge-collapsed on show and restores it on hide, fixing invisible content and pointer-event steal in the collapsed slot - Shell element: overflow:clip + isolation:isolate for stacking context - dv-content-container: overflow:hidden on edge groups prevents bleed - pinned state serialised in toJSON and restored in fromJSON - DockviewGroupPanelPinnedChangeEvent exported from index.ts - 100 new tests across dockviewShell.spec.ts and dockviewGroupPanelApi.spec.ts --- __generated__/dockview-core-exports.txt | 1 + .../api/dockviewGroupPanelApi.spec.ts | 107 +++++++ .../__tests__/dockview/dockviewShell.spec.ts | 271 ++++++++++++++++ .../src/api/dockviewGroupPanelApi.ts | 41 +++ .../src/dockview/dockviewComponent.scss | 12 + .../src/dockview/dockviewComponent.ts | 19 ++ .../src/dockview/dockviewGroupPanel.scss | 19 ++ .../src/dockview/dockviewShell.ts | 290 +++++++++++++++++- packages/dockview-core/src/index.ts | 1 + 9 files changed, 759 insertions(+), 2 deletions(-) diff --git a/__generated__/dockview-core-exports.txt b/__generated__/dockview-core-exports.txt index 3cd4f81ebc..3b007c07ba 100644 --- a/__generated__/dockview-core-exports.txt +++ b/__generated__/dockview-core-exports.txt @@ -44,6 +44,7 @@ DockviewGroupPanelApi DockviewGroupPanelCollapsedChangeEvent DockviewGroupPanelFloatingChangeEvent DockviewGroupPanelModel +DockviewGroupPanelPinnedChangeEvent DockviewHeaderDirection DockviewHeaderPosition DockviewIDisposable diff --git a/packages/dockview-core/src/__tests__/api/dockviewGroupPanelApi.spec.ts b/packages/dockview-core/src/__tests__/api/dockviewGroupPanelApi.spec.ts index d2f7aece80..24b381bc9c 100644 --- a/packages/dockview-core/src/__tests__/api/dockviewGroupPanelApi.spec.ts +++ b/packages/dockview-core/src/__tests__/api/dockviewGroupPanelApi.spec.ts @@ -173,4 +173,111 @@ describe('DockviewGroupPanelApiImpl', () => { expect(events).toHaveLength(0); }); }); + + describe('setPinned / isPinned', () => { + function makeAccessor() { + return fromPartial({ + setEdgeGroupPinned: jest.fn(), + isEdgeGroupPinned: jest.fn().mockReturnValue(true), + }); + } + + test('setPinned(false) delegates to accessor.setEdgeGroupPinned', () => { + const accessor = makeAccessor(); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + const group = fromPartial({}); + cut.initialize(group); + + cut.setPinned(false); + + expect(accessor.setEdgeGroupPinned).toHaveBeenCalledWith( + group, + false + ); + }); + + test('isPinned() delegates to accessor.isEdgeGroupPinned and returns its value', () => { + const accessor = makeAccessor(); + (accessor.isEdgeGroupPinned as jest.Mock).mockReturnValue(false); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + const group = fromPartial({}); + cut.initialize(group); + + expect(cut.isPinned()).toBe(false); + expect(accessor.isEdgeGroupPinned).toHaveBeenCalledWith(group); + }); + + test('isPinned() returns true when group is not initialized', () => { + const accessor = makeAccessor(); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + + expect(cut.isPinned()).toBe(true); + expect(accessor.isEdgeGroupPinned).not.toHaveBeenCalled(); + }); + + test('setPinned() is a no-op when group is not initialized', () => { + const accessor = makeAccessor(); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + + expect(() => cut.setPinned(false)).not.toThrow(); + expect(accessor.setEdgeGroupPinned).not.toHaveBeenCalled(); + }); + }); + + describe('onDidPinnedChange', () => { + function makeAccessor() { + return fromPartial({ + setEdgeGroupPinned: jest.fn(), + isEdgeGroupPinned: jest.fn().mockReturnValue(true), + }); + } + + test('emits with isPinned=false when _onDidPinnedChange is fired', () => { + const accessor = makeAccessor(); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + const group = fromPartial({ id: 'g1' }); + cut.initialize(group); + + const events: { isPinned: boolean }[] = []; + cut.onDidPinnedChange((e) => events.push(e)); + + cut._onDidPinnedChange.fire({ isPinned: false }); + + expect(events).toHaveLength(1); + expect(events[0].isPinned).toBe(false); + }); + + test('emits with isPinned=true when pinned is restored', () => { + const accessor = makeAccessor(); + const cut = new DockviewGroupPanelApiImpl( + 'test-id', + accessor as unknown as DockviewComponent + ); + const group = fromPartial({ id: 'g1' }); + cut.initialize(group); + + const events: { isPinned: boolean }[] = []; + cut.onDidPinnedChange((e) => events.push(e)); + + cut._onDidPinnedChange.fire({ isPinned: true }); + + expect(events).toHaveLength(1); + expect(events[0].isPinned).toBe(true); + }); + }); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts index cfe60e2dda..ccfe93bc32 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts @@ -71,6 +71,25 @@ describe('EdgeGroupView', () => { expect(group.element.dataset.testid).toBe('dv-edge-group-my-panel'); }); + test('isPinned is true by default', () => { + const group = makeGroup(); + const view = new EdgeGroupView({ id: 'test' }, group, 'horizontal'); + expect(view.isPinned).toBe(true); + }); + + test('isPinned is false when pinned option is false', () => { + const group = makeGroup(); + const view = new EdgeGroupView( + { id: 'test', pinned: false }, + group, + 'horizontal' + ); + expect(view.isPinned).toBe(false); + expect(group.element.classList.contains('dv-edge-unpinned')).toBe( + true + ); + }); + test('isCollapsed is true when collapsed option is true', () => { const group = makeGroup(); const view = new EdgeGroupView( @@ -459,6 +478,22 @@ describe('ShellManager', () => { expect(shell.isEdgeGroupVisible('bottom')).toBe(false); shell.dispose(); }); + + test('restores pinned=false from serialized state', () => { + const shell = makeShell({ left: { id: 'left' } }); + shell.fromJSON({ + left: { size: 200, visible: true, pinned: false }, + }); + expect(shell.isEdgeGroupPinned('left')).toBe(false); + shell.dispose(); + }); + + test('pinned defaults to true when absent from serialized state', () => { + const shell = makeShell({ left: { id: 'left' } }); + shell.fromJSON({ left: { size: 200, visible: true } }); + expect(shell.isEdgeGroupPinned('left')).toBe(true); + shell.dispose(); + }); }); describe('defaultCollapsedSize', () => { @@ -586,6 +621,242 @@ describe('ShellManager', () => { }); }); + describe('unpinned overlay behaviour', () => { + test('expanding an unpinned group creates an overlay element', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + + shell.setEdgeGroupCollapsed('left', false); + + expect((shell as any)._overlayElement).not.toBeNull(); + expect((shell as any)._overlayPosition).toBe('left'); + shell.dispose(); + }); + + test('collapsing an unpinned group hides the overlay', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupCollapsed('left', false); + + shell.setEdgeGroupCollapsed('left', true); + + expect((shell as any)._overlayElement).toBeNull(); + expect((shell as any)._overlayPosition).toBeNull(); + shell.dispose(); + }); + + test('switching to unpinned collapses an expanded group in the splitview', () => { + const shell = makeShell({ left: { id: 'left' } }); + expect(shell.isEdgeGroupCollapsed('left')).toBe(false); + + shell.setEdgeGroupPinned('left', false); + + expect(shell.isEdgeGroupCollapsed('left')).toBe(true); + shell.dispose(); + }); + + test('switching unpinned→pinned while overlay is open closes overlay and expands in layout', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupCollapsed('left', false); // open overlay + + shell.setEdgeGroupPinned('left', true); // restore pin + + expect((shell as any)._overlayElement).toBeNull(); + expect(shell.isEdgeGroupCollapsed('left')).toBe(false); + shell.dispose(); + }); + + test('overlay is cleaned up on shell dispose', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupCollapsed('left', false); + expect((shell as any)._overlayElement).not.toBeNull(); + + shell.dispose(); + // No throw and overlay reference cleared + expect((shell as any)._overlayElement).toBeNull(); + }); + + test('pinned group expand/collapse still goes through splitview (not overlay)', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + // pinned by default + shell.setEdgeGroupCollapsed('left', false); + + expect((shell as any)._overlayElement).toBeNull(); + expect(shell.isEdgeGroupCollapsed('left')).toBe(false); + shell.dispose(); + }); + + test('group element is moved into overlay div and a placeholder is inserted in its slot', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + const leftView = (shell as any)._leftView as EdgeGroupView; + const groupEl = leftView.element; + // Before opening: group element is inside the dv-view slot + const slotBefore = groupEl.parentElement; + expect(slotBefore).not.toBeNull(); + + shell.setEdgeGroupCollapsed('left', false); + + const overlay: HTMLElement = (shell as any)._overlayElement; + // Group element is now inside the overlay div + expect(overlay.contains(groupEl)).toBe(true); + // A transparent placeholder sits in the original slot + const placeholder: HTMLElement = (shell as any)._overlayPlaceholder; + expect(placeholder).not.toBeNull(); + expect(slotBefore!.contains(placeholder)).toBe(true); + shell.dispose(); + }); + + test('group element is restored to its original slot and placeholder removed on hide', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + const leftView = (shell as any)._leftView as EdgeGroupView; + const groupEl = leftView.element; + const slotBefore = groupEl.parentElement; + + shell.setEdgeGroupCollapsed('left', false); + const placeholder: HTMLElement = (shell as any)._overlayPlaceholder; + + shell.setEdgeGroupCollapsed('left', true); // hide overlay + + // Placeholder removed from DOM + expect(placeholder.parentElement).toBeNull(); + // Group element is back in the original slot + expect(slotBefore!.contains(groupEl)).toBe(true); + expect((shell as any)._overlayPlaceholder).toBeNull(); + shell.dispose(); + }); + + test('switching unpinned→pinned while overlay already auto-dismissed still expands in layout', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupCollapsed('left', false); // open overlay + + // Simulate auto-dismiss (e.g. pointerdown outside) before setPinned runs + shell.setEdgeGroupCollapsed('left', true); // overlay closed, group back collapsed + expect((shell as any)._overlayElement).toBeNull(); + + // Now pin — should expand even though overlay was already dismissed + shell.setEdgeGroupPinned('left', true); + + expect(shell.isEdgeGroupCollapsed('left')).toBe(false); + expect((shell as any)._overlayElement).toBeNull(); + shell.dispose(); + }); + + test('dv-edge-overlay-visible class is added on show and removed on hide', () => { + const shell = makeShell({ left: { id: 'left', collapsed: true } }); + shell.setEdgeGroupPinned('left', false); + const leftView = (shell as any)._leftView as EdgeGroupView; + const groupEl = leftView.element; + + shell.setEdgeGroupCollapsed('left', false); + expect(groupEl.classList.contains('dv-edge-overlay-visible')).toBe( + true + ); + + shell.setEdgeGroupCollapsed('left', true); + expect(groupEl.classList.contains('dv-edge-overlay-visible')).toBe( + false + ); + shell.dispose(); + }); + + test('opening a second overlay dismisses the first', () => { + const shell = makeShell({ + left: { id: 'left', collapsed: true }, + right: { id: 'right', collapsed: true }, + }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupPinned('right', false); + + shell.setEdgeGroupCollapsed('left', false); + expect((shell as any)._overlayPosition).toBe('left'); + + shell.setEdgeGroupCollapsed('right', false); + expect((shell as any)._overlayPosition).toBe('right'); + // Left overlay must be gone + const leftView = (shell as any)._leftView as EdgeGroupView; + expect( + leftView.element.classList.contains('dv-edge-overlay-visible') + ).toBe(false); + shell.dispose(); + }); + }); + + describe('setEdgeGroupPinned / isEdgeGroupPinned', () => { + test('pinned is true by default', () => { + const shell = makeShell({ left: { id: 'left' } }); + expect(shell.isEdgeGroupPinned('left')).toBe(true); + shell.dispose(); + }); + + test('pinned can be set to false', () => { + const shell = makeShell({ left: { id: 'left' } }); + shell.setEdgeGroupPinned('left', false); + expect(shell.isEdgeGroupPinned('left')).toBe(false); + shell.dispose(); + }); + + test('pinned can be toggled back to true', () => { + const shell = makeShell({ left: { id: 'left' } }); + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupPinned('left', true); + expect(shell.isEdgeGroupPinned('left')).toBe(true); + shell.dispose(); + }); + + test('pinned=false adds dv-edge-unpinned CSS class', () => { + const shell = makeShell({ left: { id: 'left' } }); + const leftView = (shell as any)._leftView as EdgeGroupView; + shell.setEdgeGroupPinned('left', false); + expect( + leftView.element.classList.contains('dv-edge-unpinned') + ).toBe(true); + shell.dispose(); + }); + + test('pinned=true removes dv-edge-unpinned CSS class', () => { + const shell = makeShell({ left: { id: 'left' } }); + const leftView = (shell as any)._leftView as EdgeGroupView; + shell.setEdgeGroupPinned('left', false); + shell.setEdgeGroupPinned('left', true); + expect( + leftView.element.classList.contains('dv-edge-unpinned') + ).toBe(false); + shell.dispose(); + }); + + test('unconfigured position returns true (default) and does not throw', () => { + const shell = makeShell({}); + expect(shell.isEdgeGroupPinned('left')).toBe(true); + expect(() => shell.setEdgeGroupPinned('left', false)).not.toThrow(); + shell.dispose(); + }); + + test('pinned option false initialises unpinned', () => { + const shell = makeShell({ right: { id: 'right', pinned: false } }); + expect(shell.isEdgeGroupPinned('right')).toBe(false); + shell.dispose(); + }); + + test('works for all four positions', () => { + const shell = makeShell({ + top: { id: 'top' }, + bottom: { id: 'bottom' }, + left: { id: 'left' }, + right: { id: 'right' }, + }); + for (const pos of ['top', 'bottom', 'left', 'right'] as const) { + shell.setEdgeGroupPinned(pos, false); + expect(shell.isEdgeGroupPinned(pos)).toBe(false); + } + shell.dispose(); + }); + }); + describe('updateTheme', () => { test('switching gap=0→10 updates collapsed sizes on all panels', () => { // Only left+right configured: outerN=3, outerGapAdd = 10*2/3 diff --git a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts index 504d529413..723b18a5b9 100644 --- a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts @@ -31,6 +31,10 @@ export interface DockviewGroupPanelCollapsedChangeEvent { readonly isCollapsed: boolean; } +export interface DockviewGroupPanelPinnedChangeEvent { + readonly isPinned: boolean; +} + export interface DockviewGroupPanelApi extends GridviewPanelApi { readonly onDidLocationChange: Event; readonly onDidActivePanelChange: Event; @@ -39,6 +43,11 @@ export interface DockviewGroupPanelApi extends GridviewPanelApi { * Never fires for non-edge groups. */ readonly onDidCollapsedChange: Event; + /** + * Fired when an edge group's pinned state changes. + * Never fires for non-edge groups. + */ + readonly onDidPinnedChange: Event; readonly location: DockviewGroupLocation; /** * Whether this group is locked against drop interactions. @@ -71,6 +80,18 @@ export interface DockviewGroupPanelApi extends GridviewPanelApi { * Always returns false for non-edge groups. */ isCollapsed(): boolean; + /** + * Pin or unpin this edge group. + * Pinned groups push the layout when expanded (default). + * Unpinned groups expand as a floating overlay over the content. + * No-op for non-edge groups. + */ + setPinned(pinned: boolean): void; + /** + * Returns true if this edge group is pinned. + * Always returns true for non-edge groups. + */ + isPinned(): boolean; } export interface DockviewGroupPanelFloatingChangeEvent { @@ -97,6 +118,11 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { readonly onDidCollapsedChange: Event = this._onDidCollapsedChange.event; + readonly _onDidPinnedChange = + new Emitter(); + readonly onDidPinnedChange: Event = + this._onDidPinnedChange.event; + get location(): DockviewGroupLocation { if (!this._group) { throw new Error(NOT_INITIALIZED_MESSAGE); @@ -128,6 +154,7 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { this._onDidLocationChange, this._onDidActivePanelChange, this._onDidCollapsedChange, + this._onDidPinnedChange, this._onDidVisibilityChange.event((event) => { // When becoming visible, apply any pending size change if (event.isVisible && this._pendingSize) { @@ -250,6 +277,20 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { return this.accessor.isEdgeGroupCollapsed(this._group); } + setPinned(pinned: boolean): void { + if (!this._group) { + return; + } + this.accessor.setEdgeGroupPinned(this._group, pinned); + } + + isPinned(): boolean { + if (!this._group) { + return true; + } + return this.accessor.isEdgeGroupPinned(this._group); + } + initialize(group: DockviewGroupPanel): void { this._group = group; } diff --git a/packages/dockview-core/src/dockview/dockviewComponent.scss b/packages/dockview-core/src/dockview/dockviewComponent.scss index 241ab8a109..03d451a023 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -1,3 +1,15 @@ +// Floating overlay container for unpinned edge groups +.dv-edge-overlay { + position: absolute; + overflow: hidden; + z-index: var(--dv-overlay-z-index, 999); + + > .dv-groupview { + width: 100%; + height: 100%; + } +} + .dv-dockview { position: relative; background-color: var(--dv-group-view-background-color); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 7f265933da..b82f368839 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -1713,6 +1713,25 @@ export class DockviewComponent return false; } + setEdgeGroupPinned(group: DockviewGroupPanel, pinned: boolean): void { + for (const [position, edgeGroup] of this._edgeGroups) { + if (edgeGroup === group) { + this._shellManager!.setEdgeGroupPinned(position, pinned); + edgeGroup.api._onDidPinnedChange.fire({ isPinned: pinned }); + return; + } + } + } + + isEdgeGroupPinned(group: DockviewGroupPanel): boolean { + for (const [position, edgeGroup] of this._edgeGroups) { + if (edgeGroup === group) { + return this._shellManager!.isEdgeGroupPinned(position); + } + } + return true; + } + private updateDragAndDropState(): void { // Update draggable state for all tabs and void containers for (const group of this.groups) { diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanel.scss b/packages/dockview-core/src/dockview/dockviewGroupPanel.scss index 9e6acf54c7..47dbab08c0 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanel.scss +++ b/packages/dockview-core/src/dockview/dockviewGroupPanel.scss @@ -27,7 +27,26 @@ flex-direction: row-reverse; } + // Edge groups: clip content so that height:100% inside panel content + // resolves against the content container, not the group or overlay element. + // Without this, panel content overflows the content area into the tab strip + // and steals pointer events, making tabs and buttons unclickable. + &.dv-groupview-edge > .dv-content-container { + overflow: hidden; + } + &.dv-groupview-edge.dv-edge-collapsed > .dv-content-container { display: none; } + + // Unpinned groups: content is hidden in the layout slot (only tab strip shows) + &.dv-groupview-edge.dv-edge-unpinned > .dv-content-container { + display: none; + } + + // When the overlay is visible, show content inside the overlay div. + // Override both dv-edge-collapsed and dv-edge-unpinned hide rules. + &.dv-groupview-edge.dv-edge-overlay-visible > .dv-content-container { + display: flex; + } } diff --git a/packages/dockview-core/src/dockview/dockviewShell.ts b/packages/dockview-core/src/dockview/dockviewShell.ts index 4d4d7d7629..7e8a295b5e 100644 --- a/packages/dockview-core/src/dockview/dockviewShell.ts +++ b/packages/dockview-core/src/dockview/dockviewShell.ts @@ -1,5 +1,9 @@ -import { Emitter, Event } from '../events'; -import { CompositeDisposable, IDisposable } from '../lifecycle'; +import { Emitter, Event, addDisposableListener } from '../events'; +import { + CompositeDisposable, + IDisposable, + MutableDisposable, +} from '../lifecycle'; import { IView, LayoutPriority, @@ -17,6 +21,12 @@ export interface EdgeGroupOptions { maximumSize?: number; collapsedSize?: number; collapsed?: boolean; + /** + * When true (default) the group pushes the layout when expanded. + * When false the group is always collapsed in the layout and expands + * as a floating overlay on demand. + */ + pinned?: boolean; } export interface SerializedEdgeGroups { @@ -24,24 +34,28 @@ export interface SerializedEdgeGroups { size: number; visible: boolean; collapsed?: boolean; + pinned?: boolean; group?: unknown; }; bottom?: { size: number; visible: boolean; collapsed?: boolean; + pinned?: boolean; group?: unknown; }; left?: { size: number; visible: boolean; collapsed?: boolean; + pinned?: boolean; group?: unknown; }; right?: { size: number; visible: boolean; collapsed?: boolean; + pinned?: boolean; group?: unknown; }; } @@ -70,6 +84,7 @@ export class EdgeGroupView implements IView { readonly priority = LayoutPriority.Low; private _isCollapsed = false; + private _isPinned = true; private _lastExpandedSize: number; private _collapsedSize: number; private _expandedMinimumSize: number; @@ -97,6 +112,10 @@ export class EdgeGroupView implements IView { return this._isCollapsed; } + get isPinned(): boolean { + return this._isPinned; + } + get lastExpandedSize(): number { return this._lastExpandedSize; } @@ -129,12 +148,24 @@ export class EdgeGroupView implements IView { this._lastExpandedSize = options.initialSize ?? 200; + // pinned defaults to true — unpinned groups float over content + this._isPinned = options.pinned !== false; + group.element.classList.toggle('dv-edge-unpinned', !this._isPinned); + if (options.collapsed) { this._isCollapsed = true; group.element.classList.add('dv-edge-collapsed'); } } + setPinned(pinned: boolean): void { + if (this._isPinned === pinned) { + return; + } + this._isPinned = pinned; + this._group.element.classList.toggle('dv-edge-unpinned', !pinned); + } + layout(size: number, orthogonalSize: number): void { // Track the last expanded size so we can restore it after collapsing if (!this._isCollapsed) { @@ -391,6 +422,7 @@ export class ShellManager implements IDisposable { private _rightIndex: number | undefined; private readonly _disposables = new CompositeDisposable(); + private readonly _overlayDismissDisposable = new MutableDisposable(); // Retained for updateTheme() recalculations. private readonly _viewConfigs = new Map< @@ -402,6 +434,11 @@ export class ShellManager implements IDisposable { private _gap: number; private _defaultCollapsedSize: number; + // Overlay state for unpinned groups + private _overlayElement: HTMLElement | null = null; + private _overlayPosition: EdgeGroupPosition | null = null; + private _overlayPlaceholder: HTMLElement | null = null; + constructor( container: HTMLElement, dockviewElement: HTMLElement, @@ -417,6 +454,8 @@ export class ShellManager implements IDisposable { this._shellElement.style.height = '100%'; this._shellElement.style.width = '100%'; this._shellElement.style.position = 'relative'; + this._shellElement.style.overflow = 'clip'; + this._shellElement.style.isolation = 'isolate'; container.appendChild(this._shellElement); const centerView = new CenterView(dockviewElement, layoutGrid); @@ -745,6 +784,17 @@ export class ShellManager implements IDisposable { if (!view) { return; } + + // Unpinned groups: expand/collapse as floating overlay, not splitview resize + if (!view.isPinned) { + if (collapsed) { + this._hideOverlay(); + } else { + this._showOverlay(position, view); + } + return; + } + view.setCollapsed(collapsed); const targetSize = collapsed ? view.collapsedSize @@ -773,10 +823,229 @@ export class ShellManager implements IDisposable { } } + private _showOverlay( + position: EdgeGroupPosition, + view: EdgeGroupView + ): void { + // Dismiss any existing overlay first + this._hideOverlay(); + + const groupEl = view.element; + const collapsedSize = view.collapsedSize; + const expandedSize = view.lastExpandedSize; + + // The group element lives inside dv-view (overflow:auto) inside the + // splitview (overflow:hidden). We must move it out of those clipping + // ancestors and into an absolutely-positioned overlay div on the shell. + // To avoid the empty dv-view slot showing a white background, we insert + // a transparent placeholder that keeps the splitview slot "filled". + const placeholder = document.createElement('div'); + placeholder.style.width = '100%'; + placeholder.style.height = '100%'; + placeholder.style.background = 'transparent'; + groupEl.parentElement?.insertBefore(placeholder, groupEl); + this._overlayPlaceholder = placeholder; + + const overlay = document.createElement('div'); + overlay.className = 'dv-edge-overlay'; + overlay.style.position = 'absolute'; + overlay.style.zIndex = 'var(--dv-overlay-z-index, 999)'; + + // Position the overlay to cover tab strip + expanded content area. + // Left/right groups span the full shell height; top/bottom groups live + // inside the middle column so their overlay must be horizontally + // constrained to the middle column (not the full shell width). + switch (position) { + case 'left': + overlay.style.left = '0'; + overlay.style.top = '0'; + overlay.style.bottom = '0'; + overlay.style.width = `${collapsedSize + expandedSize}px`; + break; + case 'right': + overlay.style.right = '0'; + overlay.style.top = '0'; + overlay.style.bottom = '0'; + overlay.style.width = `${collapsedSize + expandedSize}px`; + break; + case 'top': { + const midEl = this._middleColumn.element; + const shellRect = this._shellElement.getBoundingClientRect(); + const midRect = midEl.getBoundingClientRect(); + const midLeft = midRect.left - shellRect.left; + overlay.style.top = '0'; + overlay.style.left = `${midLeft}px`; + overlay.style.width = `${midRect.width}px`; + overlay.style.height = `${collapsedSize + expandedSize}px`; + break; + } + case 'bottom': { + const midEl = this._middleColumn.element; + const shellRect = this._shellElement.getBoundingClientRect(); + const midRect = midEl.getBoundingClientRect(); + const midLeft = midRect.left - shellRect.left; + overlay.style.bottom = '0'; + overlay.style.left = `${midLeft}px`; + overlay.style.width = `${midRect.width}px`; + overlay.style.height = `${collapsedSize + expandedSize}px`; + break; + } + } + + overlay.appendChild(groupEl); + this._shellElement.appendChild(overlay); + + // Remove dv-edge-collapsed so the group lays out with full content in + // the overlay (restored when the overlay closes in _hideOverlay). + groupEl.classList.remove('dv-edge-collapsed'); + + // Trigger a layout so the group renders at its new expanded size + if (position === 'left' || position === 'right') { + view.layout(collapsedSize + expandedSize, this._currentHeight); + } else { + const midWidth = + this._middleColumn.element.getBoundingClientRect().width; + view.layout(collapsedSize + expandedSize, midWidth); + } + + groupEl.classList.add('dv-edge-overlay-visible'); + this._overlayElement = overlay; + this._overlayPosition = position; + + // Dismiss when the user clicks outside the overlay + this._overlayDismissDisposable.value = addDisposableListener( + window, + 'pointerdown', + (e) => { + if (!overlay.contains(e.target as Node)) { + this._hideOverlay(); + } + }, + true + ); + } + + private _hideOverlay(): void { + if (!this._overlayElement || !this._overlayPosition) { + return; + } + + const position = this._overlayPosition; + const view = this._getView(position); + const overlay = this._overlayElement; + const placeholder = this._overlayPlaceholder; + + this._overlayDismissDisposable.dispose(); + this._overlayElement = null; + this._overlayPosition = null; + this._overlayPlaceholder = null; + + if (view) { + view.element.classList.remove('dv-edge-overlay-visible'); + if (view.isCollapsed) { + view.element.classList.add('dv-edge-collapsed'); + } + overlay.removeChild(view.element); + // Restore into the splitview slot, replacing the placeholder + if (placeholder?.parentElement) { + placeholder.parentElement.insertBefore( + view.element, + placeholder + ); + placeholder.remove(); + } + // Re-layout at collapsed size so the tab strip renders correctly. + // For left/right the orthogonalSize is height; for top/bottom it's + // the middle column width. + if (view.isCollapsed) { + const isHorizontal = + position === 'left' || position === 'right'; + const ortho = isHorizontal + ? this._currentHeight + : this._middleColumn.element.getBoundingClientRect().width; + view.layout(view.collapsedSize, ortho); + } + } + + overlay.remove(); + } + isEdgeGroupCollapsed(position: EdgeGroupPosition): boolean { return this._getView(position)?.isCollapsed ?? false; } + setEdgeGroupPinned(position: EdgeGroupPosition, pinned: boolean): void { + const view = this._getView(position); + if (!view) { + return; + } + view.setPinned(pinned); + + if (!pinned) { + // Switching to unpinned: collapse in layout (tab strip only) + if (!view.isCollapsed) { + view.setCollapsed(true); + const targetSize = view.collapsedSize; + switch (position) { + case 'left': + if (this._leftIndex !== undefined) { + this._outerSplitview.resizeView( + this._leftIndex, + targetSize + ); + } + break; + case 'right': + if (this._rightIndex !== undefined) { + this._outerSplitview.resizeView( + this._rightIndex, + targetSize + ); + } + break; + case 'top': + case 'bottom': + this._middleColumn.resizeView(position, targetSize); + break; + } + } + } else { + // Switching to pinned: close any open overlay, then expand in layout. + // The overlay may have already been auto-dismissed (e.g. by the + // pointerdown capture listener firing before the click handler), so + // always expand regardless of whether _overlayPosition matches. + this._hideOverlay(); + view.setCollapsed(false); + const targetSize = view.lastExpandedSize; + switch (position) { + case 'left': + if (this._leftIndex !== undefined) { + this._outerSplitview.resizeView( + this._leftIndex, + targetSize + ); + } + break; + case 'right': + if (this._rightIndex !== undefined) { + this._outerSplitview.resizeView( + this._rightIndex, + targetSize + ); + } + break; + case 'top': + case 'bottom': + this._middleColumn.resizeView(position, targetSize); + break; + } + } + } + + isEdgeGroupPinned(position: EdgeGroupPosition): boolean { + return this._getView(position)?.isPinned ?? true; + } + private _getView(position: EdgeGroupPosition): EdgeGroupView | undefined { switch (position) { case 'top': @@ -800,6 +1069,7 @@ export class ShellManager implements IDisposable { : this._outerSplitview.getViewSize(this._leftIndex), visible: this._outerSplitview.isViewVisible(this._leftIndex), collapsed: this._leftView.isCollapsed || undefined, + pinned: this._leftView.isPinned ? undefined : false, }; } if (this._rightView && this._rightIndex !== undefined) { @@ -809,6 +1079,7 @@ export class ShellManager implements IDisposable { : this._outerSplitview.getViewSize(this._rightIndex), visible: this._outerSplitview.isViewVisible(this._rightIndex), collapsed: this._rightView.isCollapsed || undefined, + pinned: this._rightView.isPinned ? undefined : false, }; } if (this._topView) { @@ -818,6 +1089,7 @@ export class ShellManager implements IDisposable { : this._middleColumn.getViewSize('top'), visible: this._middleColumn.isViewVisible('top'), collapsed: this._topView.isCollapsed || undefined, + pinned: this._topView.isPinned ? undefined : false, }; } if (this._bottomView) { @@ -827,6 +1099,7 @@ export class ShellManager implements IDisposable { : this._middleColumn.getViewSize('bottom'), visible: this._middleColumn.isViewVisible('bottom'), collapsed: this._bottomView.isCollapsed || undefined, + pinned: this._bottomView.isPinned ? undefined : false, }; } @@ -840,6 +1113,9 @@ export class ShellManager implements IDisposable { // be applied before setCollapsed locks min/max to collapsedSize. this._leftView?.restoreExpandedSize(data.left.size); this._leftView?.setCollapsed(data.left.collapsed ?? false); + if (data.left.pinned === false) { + this._leftView?.setPinned(false); + } this._outerSplitview.resizeView( this._leftIndex, data.left.collapsed @@ -853,6 +1129,9 @@ export class ShellManager implements IDisposable { if (data.right && this._rightIndex !== undefined) { this._rightView?.restoreExpandedSize(data.right.size); this._rightView?.setCollapsed(data.right.collapsed ?? false); + if (data.right.pinned === false) { + this._rightView?.setPinned(false); + } this._outerSplitview.resizeView( this._rightIndex, data.right.collapsed @@ -866,6 +1145,9 @@ export class ShellManager implements IDisposable { if (data.top) { this._topView?.restoreExpandedSize(data.top.size); this._topView?.setCollapsed(data.top.collapsed ?? false); + if (data.top.pinned === false) { + this._topView?.setPinned(false); + } this._middleColumn.resizeView( 'top', data.top.collapsed @@ -879,6 +1161,9 @@ export class ShellManager implements IDisposable { if (data.bottom) { this._bottomView?.restoreExpandedSize(data.bottom.size); this._bottomView?.setCollapsed(data.bottom.collapsed ?? false); + if (data.bottom.pinned === false) { + this._bottomView?.setPinned(false); + } this._middleColumn.resizeView( 'bottom', data.bottom.collapsed @@ -892,6 +1177,7 @@ export class ShellManager implements IDisposable { } dispose(): void { + this._hideOverlay(); this._disposables.dispose(); this._shellElement.parentElement?.removeChild(this._shellElement); } diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index 2ab3b3e8d2..b6f28b6b4c 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -160,6 +160,7 @@ export { DockviewGroupPanelApi, DockviewGroupPanelFloatingChangeEvent, DockviewGroupPanelCollapsedChangeEvent, + DockviewGroupPanelPinnedChangeEvent, DockviewGroupMoveParams, } from './api/dockviewGroupPanelApi'; export {