diff --git a/packages/dockview-core/src/__tests__/gridview/a.html b/packages/dockview-core/src/__tests__/gridview/a.html new file mode 100644 index 000000000..36620ace6 --- /dev/null +++ b/packages/dockview-core/src/__tests__/gridview/a.html @@ -0,0 +1,75 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ < /div> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ < /div> +
+
+
+
+
diff --git a/packages/dockview-core/src/api/component.api.ts b/packages/dockview-core/src/api/component.api.ts index c46a2aa56..8432ea312 100644 --- a/packages/dockview-core/src/api/component.api.ts +++ b/packages/dockview-core/src/api/component.api.ts @@ -804,4 +804,16 @@ export class DockviewApi implements CommonApi { moveToPrevious(options?: MovementOptions): void { this.component.moveToPrevious(options); } + + maximizeGroup(panel: IDockviewPanel): void { + this.component.maximizeGroup(panel.group); + } + + hasMaximizedGroup(): boolean { + return this.component.hasMaximizedGroup(); + } + + exitMaxmizedGroup(): void { + this.component.exitMaximizedGroup(); + } } diff --git a/packages/dockview-core/src/api/dockviewPanelApi.ts b/packages/dockview-core/src/api/dockviewPanelApi.ts index e724c251d..4ed3a4054 100644 --- a/packages/dockview-core/src/api/dockviewPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewPanelApi.ts @@ -31,6 +31,7 @@ export interface DockviewPanelApi position?: Position; index?: number; }): void; + maximize(): void; } export class DockviewPanelApiImpl @@ -120,4 +121,8 @@ export class DockviewPanelApiImpl close(): void { this.group.model.closePanel(this.panel); } + + maximize(): void { + this.accessor.maximizeGroup(this.panel.group); + } } diff --git a/packages/dockview-core/src/dockview/dockviewComponent.scss b/packages/dockview-core/src/dockview/dockviewComponent.scss index 1aa878539..d32b9ddaf 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -10,6 +10,35 @@ width: 100%; z-index: 1; } + + .dv-fullscreen { + z-index: 9; + transition: all 0.25s ease-in-out; + // outline: 1px solid red; + position: absolute; + } + + .dv-fullscreen-animate { + top: 0px !important; + left: 0px !important; + width: 100% !important; + height: 100% !important; + } + + .dv-fullscreen-close-button { + position: absolute; + bottom: 0px; + right: 0px; + z-index: 999; + width: 20px; + height: 20px; + color: white; + background-color: black; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } } .groupview { diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index e7ab959c2..9fb4f18dd 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -7,7 +7,7 @@ import { import { directionToPosition, Droptarget, Position } from '../dnd/droptarget'; import { tail, sequenceEquals, remove } from '../array'; import { DockviewPanel, IDockviewPanel } from './dockviewPanel'; -import { CompositeDisposable } from '../lifecycle'; +import { CompositeDisposable, IDisposable } from '../lifecycle'; import { Event, Emitter } from '../events'; import { Watermark } from './components/watermark/watermark'; import { @@ -46,7 +46,11 @@ import { DockviewPanelModel } from './dockviewPanelModel'; import { getPanelData } from '../dnd/dataTransfer'; import { Parameters } from '../panel/types'; import { Overlay } from '../dnd/overlay'; -import { toggleClass, watchElementResize } from '../dom'; +import { + FocusTrap as FocusRetainment, + toggleClass, + watchElementResize, +} from '../dom'; import { DockviewFloatingGroupPanel, IDockviewFloatingGroupPanel, @@ -55,6 +59,7 @@ import { GroupDragEvent, TabDragEvent, } from './components/titlebar/tabsContainer'; +import { createCloseButton } from '../svg'; const DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE = 100; diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanel.ts index 6823cbd4d..9388ced17 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanel.ts @@ -68,8 +68,8 @@ export class DockviewGroupPanel id, 'groupview_default', { - minimumHeight: 100, - minimumWidth: 100, + minimumHeight: 0, + minimumWidth: 0, }, new DockviewGroupPanelApiImpl(id, accessor) ); diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 43ee03b01..5a8abf6b4 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -130,6 +130,8 @@ export interface IDockviewGroupPanelModel extends IPanel { ): boolean; } +export type DockviewGroupMode = 'grid' | 'floating' | 'fullscreen' | 'popout'; + export class DockviewGroupPanelModel extends CompositeDisposable implements IDockviewGroupPanelModel diff --git a/packages/dockview-core/src/dom.ts b/packages/dockview-core/src/dom.ts index a12b50742..ac33d710f 100644 --- a/packages/dockview-core/src/dom.ts +++ b/packages/dockview-core/src/dom.ts @@ -185,3 +185,24 @@ export function quasiPreventDefault(event: Event): void { export function quasiDefaultPrevented(event: Event): boolean { return (event as any)[QUASI_PREVENT_DEFAULT_KEY]; } + +export class FocusTrap { + private element: Element | null; + + constructor() { + this.element = null; + } + + retain(): void { + this.element = document.activeElement; + } + + focus(): void { + if ( + document.activeElement !== this.element && + this.element?.parentElement + ) { + (this.element as HTMLElement)?.focus?.(); + } + } +} diff --git a/packages/dockview-core/src/gridview/baseComponentGridview.ts b/packages/dockview-core/src/gridview/baseComponentGridview.ts index 2e9ff31c6..18992361e 100644 --- a/packages/dockview-core/src/gridview/baseComponentGridview.ts +++ b/packages/dockview-core/src/gridview/baseComponentGridview.ts @@ -64,6 +64,9 @@ export interface IBaseGrid { layout(width: number, height: number, force?: boolean): void; setVisible(panel: T, visible: boolean): void; isVisible(panel: T): boolean; + maximizeGroup(panel: T): void; + exitMaximizedGroup(): void; + hasMaximizedGroup(): boolean; } export abstract class BaseGrid @@ -174,6 +177,18 @@ export abstract class BaseGrid return this.gridview.isViewVisible(getGridLocation(panel.element)); } + maximizeGroup(panel: T): void { + this.gridview.maximizeView(panel); + } + + exitMaximizedGroup(): void { + this.gridview.exitMaximizedView(); + } + + hasMaximizedGroup(): boolean { + return this.gridview.hasMaximizedView(); + } + protected doAddGroup( group: T, location: number[] = [0], diff --git a/packages/dockview-core/src/gridview/branchNode.ts b/packages/dockview-core/src/gridview/branchNode.ts index f354a8a52..8ba96ef05 100644 --- a/packages/dockview-core/src/gridview/branchNode.ts +++ b/packages/dockview-core/src/gridview/branchNode.ts @@ -33,6 +33,10 @@ export class BranchNode extends CompositeDisposable implements IView { readonly onDidChange: Event<{ size?: number; orthogonalSize?: number }> = this._onDidChange.event; + private readonly _onDidVisibilityChange = new Emitter(); + readonly onDidVisibilityChange: Event = + this._onDidVisibilityChange.event; + get width(): number { return this.orientation === Orientation.HORIZONTAL ? this.size @@ -48,11 +52,23 @@ export class BranchNode extends CompositeDisposable implements IView { get minimumSize(): number { return this.children.length === 0 ? 0 - : Math.max(...this.children.map((c) => c.minimumOrthogonalSize)); + : Math.max( + ...this.children.map((c, index) => + this.splitview.isViewVisible(index) + ? c.minimumOrthogonalSize + : 0 + ) + ); } get maximumSize(): number { - return Math.min(...this.children.map((c) => c.maximumOrthogonalSize)); + return Math.min( + ...this.children.map((c, index) => + this.splitview.isViewVisible(index) + ? c.maximumOrthogonalSize + : Number.POSITIVE_INFINITY + ) + ); } get minimumOrthogonalSize(): number { @@ -163,6 +179,7 @@ export class BranchNode extends CompositeDisposable implements IView { this.addDisposables( this._onDidChange, + this._onDidVisibilityChange, this.splitview.onDidSashEnd(() => { this._onDidChange.fire({}); }) @@ -185,7 +202,7 @@ export class BranchNode extends CompositeDisposable implements IView { return this.splitview.isViewVisible(index); } - setChildVisible(index: number, visible: boolean): void { + setChildVisible(index: number, visible: boolean): void { if (index < 0 || index >= this.children.length) { throw new Error('Invalid index'); } @@ -194,7 +211,18 @@ export class BranchNode extends CompositeDisposable implements IView { return; } + const wereAllChildrenHidden = this.splitview.contentSize === 0; this.splitview.setViewVisible(index, visible); + const areAllChildrenHidden = this.splitview.contentSize === 0; + + // If all children are hidden then the parent should hide the entire splitview + // If the entire splitview is hidden then the parent should show the splitview when a child is shown + if ( + (visible && wereAllChildrenHidden) || + (!visible && areAllChildrenHidden) + ) { + this._onDidVisibilityChange.fire(visible); + } } moveChild(from: number, to: number): void { @@ -285,15 +313,23 @@ export class BranchNode extends CompositeDisposable implements IView { private setupChildrenEvents(): void { this._childrenDisposable.dispose(); - this._childrenDisposable = Event.any( - ...this.children.map((c) => c.onDidChange) - )((e) => { - /** - * indicate a change has occured to allows any re-rendering but don't bubble - * event because that was specific to this branch - */ - this._onDidChange.fire({ size: e.orthogonalSize }); - }); + this._childrenDisposable = new CompositeDisposable( + Event.any(...this.children.map((c) => c.onDidChange))((e) => { + /** + * indicate a change has occured to allows any re-rendering but don't bubble + * event because that was specific to this branch + */ + this._onDidChange.fire({ size: e.orthogonalSize }); + }), + ...this.children.map((c, i) => { + if (c instanceof BranchNode) { + return c.onDidVisibilityChange((visible) => { + this.setChildVisible(i, visible); + }); + } + return Disposable.NONE; + }) + ); } public dispose(): void { diff --git a/packages/dockview-core/src/gridview/gridview.ts b/packages/dockview-core/src/gridview/gridview.ts index 477286f20..c69bed0ca 100644 --- a/packages/dockview-core/src/gridview/gridview.ts +++ b/packages/dockview-core/src/gridview/gridview.ts @@ -270,9 +270,11 @@ export interface SerializedGridview { } export class Gridview implements IDisposable { + readonly element: HTMLElement; + private _root: BranchNode | undefined; - public readonly element: HTMLElement; - private disposable: MutableDisposable = new MutableDisposable(); + private _maximizedNode: Node | undefined = undefined; + private readonly disposable: MutableDisposable = new MutableDisposable(); private readonly _onDidChange = new Emitter<{ size?: number; @@ -319,6 +321,60 @@ export class Gridview implements IDisposable { return this.root.maximumHeight; } + hasMaximizedView(): boolean { + return this._maximizedNode !== undefined; + } + + maximizeView(view: IGridView): void { + const location = getGridLocation(view.element); + const [_, node] = this.getNode(location); + + if (this._maximizedNode === node) { + return; + } + + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + + function hideAllViewsBut(parent: BranchNode, exclude: LeafNode): void { + for (let i = 0; i < parent.children.length; i++) { + const child = parent.children[i]; + if (child instanceof LeafNode) { + if (child !== exclude) { + parent.setChildVisible(i, false); + } + } else { + hideAllViewsBut(child, exclude); + } + } + } + + hideAllViewsBut(this.root, node as LeafNode); + this._maximizedNode = node; + } + + exitMaximizedView(): void { + if (!this._maximizedNode) { + return; + } + + function showViewsInReverseOrder(parent: BranchNode): void { + for (let index = parent.children.length - 1; index >= 0; index--) { + const child = parent.children[index]; + if (child instanceof LeafNode) { + parent.setChildVisible(index, true); + } else { + showViewsInReverseOrder(child); + } + } + } + + showViewsInReverseOrder(this.root); + + this._maximizedNode = undefined; + } + public serialize(): SerializedGridview { const root = serializeBranchNode(this.getView(), this.orientation); @@ -584,6 +640,10 @@ export class Gridview implements IDisposable { } setViewVisible(location: number[], visible: boolean): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + const [rest, index] = tail(location); const [, parent] = this.getNode(rest); @@ -595,6 +655,10 @@ export class Gridview implements IDisposable { } public moveView(parentLocation: number[], from: number, to: number): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + const [, parent] = this.getNode(parentLocation); if (!(parent instanceof BranchNode)) { @@ -609,6 +673,10 @@ export class Gridview implements IDisposable { size: number | Sizing, location: number[] ): void { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + const [rest, index] = tail(location); const [pathToParent, parent] = this.getNode(rest); @@ -670,6 +738,10 @@ export class Gridview implements IDisposable { } removeView(location: number[], sizing?: Sizing): IGridView { + if (this.hasMaximizedView()) { + this.exitMaximizedView(); + } + const [rest, index] = tail(location); const [pathToParent, parent] = this.getNode(rest); diff --git a/packages/dockview-core/src/gridview/leafNode.ts b/packages/dockview-core/src/gridview/leafNode.ts index 3abdb57ea..2f99a6379 100644 --- a/packages/dockview-core/src/gridview/leafNode.ts +++ b/packages/dockview-core/src/gridview/leafNode.ts @@ -121,7 +121,7 @@ export class LeafNode implements IView { public setVisible(visible: boolean): void { if (this.view.setVisible) { this.view.setVisible(visible); - this._onDidChange.fire({}); + // this._onDidChange.fire({}); } } diff --git a/packages/dockview-core/src/splitview/splitview.ts b/packages/dockview-core/src/splitview/splitview.ts index 0d56cce24..c8b3fdf66 100644 --- a/packages/dockview-core/src/splitview/splitview.ts +++ b/packages/dockview-core/src/splitview/splitview.ts @@ -104,8 +104,8 @@ export class Splitview { private _orientation: Orientation; private _size = 0; private _orthogonalSize = 0; - private contentSize = 0; - private _proportions: number[] | undefined = undefined; + private _contentSize = 0; + private _proportions: (number | undefined)[] | undefined = undefined; private proportionalLayout: boolean; private _startSnappingEnabled = true; private _endSnappingEnabled = true; @@ -117,6 +117,10 @@ export class Splitview { private readonly _onDidRemoveView = new Emitter(); readonly onDidRemoveView = this._onDidRemoveView.event; + get contentSize(): number { + return this._contentSize; + } + get size(): number { return this._size; } @@ -137,7 +141,7 @@ export class Splitview { return this.viewItems.length; } - public get proportions(): number[] | undefined { + public get proportions(): (number | undefined)[] | undefined { return this._proportions ? [...this._proportions] : undefined; } @@ -242,7 +246,7 @@ export class Splitview { }); // Initialize content size and proportions for first layout - this.contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); this.saveProportions(); } } @@ -654,7 +658,7 @@ export class Splitview { } public layout(size: number, orthogonalSize: number): void { - const previousSize = Math.max(this.size, this.contentSize); + const previousSize = Math.max(this.size, this._contentSize); this.size = size; this.orthogonalSize = orthogonalSize; @@ -675,14 +679,30 @@ export class Splitview { highPriorityIndexes ); } else { + let total = 0; + for (let i = 0; i < this.viewItems.length; i++) { const item = this.viewItems[i]; + const proportion = this.proportions[i]; - item.size = clamp( - Math.round(this.proportions[i] * size), - item.minimumSize, - item.maximumSize - ); + if (typeof proportion === 'number') { + total += proportion; + } else { + size -= item.size; + } + } + + for (let i = 0; i < this.viewItems.length; i++) { + const item = this.viewItems[i]; + const proportion = this.proportions[i]; + + if (typeof proportion === 'number' && total > 0) { + item.size = clamp( + Math.round((proportion * size) / total), + item.minimumSize, + item.maximumSize + ); + } } } @@ -747,15 +767,15 @@ export class Splitview { } private saveProportions(): void { - if (this.proportionalLayout && this.contentSize > 0) { - this._proportions = this.viewItems.map( - (i) => i.size / this.contentSize + if (this.proportionalLayout && this._contentSize > 0) { + this._proportions = this.viewItems.map((i) => + i.visible ? i.size / this._contentSize : undefined ); } } private layoutViews(): void { - this.contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); + this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); let sum = 0; const x: number[] = []; @@ -880,7 +900,7 @@ export class Splitview { } else if ( snappedAfter && collapsesDown[index] && - (position < this.contentSize || this.endSnappingEnabled) + (position < this._contentSize || this.endSnappingEnabled) ) { this.updateSash(sash, SashState.MAXIMUM); } else { diff --git a/packages/docs/sandboxes/demo-dockview/src/app.tsx b/packages/docs/sandboxes/demo-dockview/src/app.tsx index 43a1c779f..fb59b38a1 100644 --- a/packages/docs/sandboxes/demo-dockview/src/app.tsx +++ b/packages/docs/sandboxes/demo-dockview/src/app.tsx @@ -13,7 +13,12 @@ import './app.scss'; const components = { default: (props: IDockviewPanelProps<{ title: string }>) => { - return
{props.params.title}
; + return ( +
+ {props.params.title} + +
+ ); }, }; @@ -194,6 +199,11 @@ const LeftControls = (props: IDockviewHeaderActionsProps) => { const PrefixHeaderControls = (props: IDockviewHeaderActionsProps) => { return (
{ + if (props.activePanel) { + props.containerApi.toggleFullscreen(props.activePanel); + } + }} className="group-control" style={{ display: 'flex', @@ -233,20 +243,20 @@ const DockviewDemo = (props: { theme?: string }) => { title: 'Panel 4', position: { referencePanel: 'panel_3', direction: 'right' }, }); - event.api.addPanel({ - id: 'panel_5', - component: 'default', - title: 'Panel 5', - position: { referencePanel: 'panel_3', direction: 'below' }, - }); - event.api.addPanel({ - id: 'panel_6', - component: 'default', - title: 'Panel 6', - position: { referencePanel: 'panel_3', direction: 'right' }, - }); + // event.api.addPanel({ + // id: 'panel_5', + // component: 'default', + // title: 'Panel 5', + // position: { referencePanel: 'panel_3', direction: 'below' }, + // }); + // event.api.addPanel({ + // id: 'panel_6', + // component: 'default', + // title: 'Panel 6', + // position: { referencePanel: 'panel_3', direction: 'right' }, + // }); - event.api.getPanel('panel_1')!.api.setActive(); + // event.api.getPanel('panel_1')!.api.setActive(); console.log(event.api.toJSON()); };