From 0575e4c1d60e9cd0a7fc100ab532b406849738c5 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Sun, 26 May 2024 22:21:52 +0100 Subject: [PATCH] feat: overflow tabs widget --- .../components/titlebar/tabPanel.scss | 8 + .../components/titlebar/tabsContainer.scss | 24 +- .../components/titlebar/tabsContainer.ts | 213 +++++++----------- .../dockview/components/titlebar/tabsPanel.ts | 158 +++++++++++++ packages/dockview-core/src/dom.ts | 30 +++ 5 files changed, 296 insertions(+), 137 deletions(-) create mode 100644 packages/dockview-core/src/dockview/components/titlebar/tabPanel.scss create mode 100644 packages/dockview-core/src/dockview/components/titlebar/tabsPanel.ts diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabPanel.scss b/packages/dockview-core/src/dockview/components/titlebar/tabPanel.scss new file mode 100644 index 000000000..f590317c8 --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabPanel.scss @@ -0,0 +1,8 @@ +.dv-dropdown-container { + position: absolute; + top: 100px; + left: 100px; + background-color: red; + margin: 10px; + z-index: 99; +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss index 4a3b57f74..61288a103 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss @@ -11,17 +11,17 @@ } &.dv-single-tab.dv-full-width-single-tab { - .tabs-container { - flex-grow: 1; - - .tab { + .tabs-container { flex-grow: 1; - } - } - .void-container { - flex-grow: 0; - } + .tab { + flex-grow: 1; + } + } + + .void-container { + flex-grow: 0; + } } .void-container { @@ -30,6 +30,12 @@ cursor: grab; } + .dv-tabs-list { + display: flex; + overflow-x: overlay; + overflow-y: hidden; + } + .tabs-container { display: flex; overflow-x: overlay; diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index 54aa322d4..86043e588 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -7,11 +7,12 @@ import { addDisposableListener, Emitter, Event } from '../../../events'; import { Tab } from '../tab/tab'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { VoidContainer } from './voidContainer'; -import { toggleClass } from '../../../dom'; +import { OverflowObserver, toggleClass } from '../../../dom'; import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; import { DockviewComponent } from '../../dockviewComponent'; import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel'; import { getPanelData } from '../../../dnd/dataTransfer'; +import { TabsPanel } from './tabsPanel'; export interface TabDropIndexEvent { readonly event: DragEvent; @@ -56,14 +57,12 @@ export class TabsContainer implements ITabsContainer { private readonly _element: HTMLElement; - private readonly tabContainer: HTMLElement; + private readonly tabContainer: TabsPanel; private readonly rightActionsContainer: HTMLElement; private readonly leftActionsContainer: HTMLElement; private readonly preActionsContainer: HTMLElement; private readonly voidContainer: VoidContainer; - private tabs: IValueDisposable[] = []; - private selectedIndex = -1; private rightActions: HTMLElement | undefined; private leftActions: HTMLElement | undefined; private preActions: HTMLElement | undefined; @@ -86,11 +85,11 @@ export class TabsContainer this._onWillShowOverlay.event; get panels(): string[] { - return this.tabs.map((_) => _.value.panel.id); + return this.tabContainer.tabs.map((_) => _.panel.id); } get size(): number { - return this.tabs.length; + return this.tabContainer.tabs.length; } get hidden(): boolean { @@ -102,73 +101,10 @@ export class TabsContainer this.element.style.display = value ? 'none' : ''; } - show(): void { - if (!this.hidden) { - this.element.style.display = ''; - } - } - - hide(): void { - this._element.style.display = 'none'; - } - - setRightActionsElement(element: HTMLElement | undefined): void { - if (this.rightActions === element) { - return; - } - if (this.rightActions) { - this.rightActions.remove(); - this.rightActions = undefined; - } - if (element) { - this.rightActionsContainer.appendChild(element); - this.rightActions = element; - } - } - - setLeftActionsElement(element: HTMLElement | undefined): void { - if (this.leftActions === element) { - return; - } - if (this.leftActions) { - this.leftActions.remove(); - this.leftActions = undefined; - } - if (element) { - this.leftActionsContainer.appendChild(element); - this.leftActions = element; - } - } - - setPrefixActionsElement(element: HTMLElement | undefined): void { - if (this.preActions === element) { - return; - } - if (this.preActions) { - this.preActions.remove(); - this.preActions = undefined; - } - if (element) { - this.preActionsContainer.appendChild(element); - this.preActions = element; - } - } - get element(): HTMLElement { return this._element; } - public isActive(tab: Tab): boolean { - return ( - this.selectedIndex > -1 && - this.tabs[this.selectedIndex].value === tab - ); - } - - public indexOf(id: string): number { - return this.tabs.findIndex((tab) => tab.value.panel.id === id); - } - constructor( private readonly accessor: DockviewComponent, private readonly group: DockviewGroupPanel @@ -193,18 +129,18 @@ export class TabsContainer this.preActionsContainer = document.createElement('div'); this.preActionsContainer.className = 'pre-actions-container'; - this.tabContainer = document.createElement('div'); - this.tabContainer.className = 'tabs-container'; + this.tabContainer = new TabsPanel(this.accessor); this.voidContainer = new VoidContainer(this.accessor, this.group); this._element.appendChild(this.preActionsContainer); - this._element.appendChild(this.tabContainer); + this._element.appendChild(this.tabContainer.element); this._element.appendChild(this.leftActionsContainer); this._element.appendChild(this.voidContainer.element); this._element.appendChild(this.rightActionsContainer); this.addDisposables( + this.tabContainer, this.accessor.onDidAddPanel((e) => { if (e.api.group === this.group) { toggleClass( @@ -237,7 +173,7 @@ export class TabsContainer this.voidContainer.onDrop((event) => { this._onDrop.fire({ event: event.nativeEvent, - index: this.tabs.length, + index: this.tabContainer.tabs.length, }); }), this.voidContainer.onWillShowOverlay((event) => { @@ -281,72 +217,104 @@ export class TabsContainer } } ), - addDisposableListener(this.tabContainer, 'mousedown', (event) => { - if (event.defaultPrevented) { - return; - } + addDisposableListener( + this.tabContainer.element, + 'mousedown', + (event) => { + if (event.defaultPrevented) { + return; + } - const isLeftClick = event.button === 0; + const isLeftClick = event.button === 0; - if (isLeftClick) { - this.accessor.doSetGroupActive(this.group); + if (isLeftClick) { + this.accessor.doSetGroupActive(this.group); + } } - }) + ) ); } - public setActive(_isGroupActive: boolean) { - // noop + public isActive(tab: Tab): boolean { + return this.tabContainer.isActive(tab); } - private addTab( - tab: IValueDisposable, - index: number = this.tabs.length - ): void { - if (index < 0 || index > this.tabs.length) { - throw new Error('invalid location'); - } + public indexOf(id: string): number { + return this.tabContainer.tabs.findIndex((tab) => tab.panel.id === id); + } - this.tabContainer.insertBefore( - tab.value.element, - this.tabContainer.children[index] - ); + show(): void { + if (!this.hidden) { + this.element.style.display = ''; + } + } - this.tabs = [ - ...this.tabs.slice(0, index), - tab, - ...this.tabs.slice(index), - ]; + hide(): void { + this._element.style.display = 'none'; + } - if (this.selectedIndex < 0) { - this.selectedIndex = index; + setRightActionsElement(element: HTMLElement | undefined): void { + if (this.rightActions === element) { + return; + } + if (this.rightActions) { + this.rightActions.remove(); + this.rightActions = undefined; + } + if (element) { + this.rightActionsContainer.appendChild(element); + this.rightActions = element; } } - public delete(id: string): void { - const index = this.tabs.findIndex((tab) => tab.value.panel.id === id); + setLeftActionsElement(element: HTMLElement | undefined): void { + if (this.leftActions === element) { + return; + } + if (this.leftActions) { + this.leftActions.remove(); + this.leftActions = undefined; + } + if (element) { + this.leftActionsContainer.appendChild(element); + this.leftActions = element; + } + } - const tabToRemove = this.tabs.splice(index, 1)[0]; + setPrefixActionsElement(element: HTMLElement | undefined): void { + if (this.preActions === element) { + return; + } + if (this.preActions) { + this.preActions.remove(); + this.preActions = undefined; + } + if (element) { + this.preActionsContainer.appendChild(element); + this.preActions = element; + } + } - const { value, disposable } = tabToRemove; + public setActive(_isGroupActive: boolean) { + // noop + } - disposable.dispose(); - value.dispose(); - value.element.remove(); + public delete(id: string): void { + this.tabContainer.removeTab(id); } public setActivePanel(panel: IDockviewPanel): void { - this.tabs.forEach((tab) => { - const isActivePanel = panel.id === tab.value.panel.id; - tab.value.setActive(isActivePanel); + this.tabContainer.tabs.forEach((tab) => { + const isActivePanel = panel.id === tab.panel.id; + tab.setActive(isActivePanel); }); } public openPanel( panel: IDockviewPanel, - index: number = this.tabs.length + index: number = this.tabContainer.tabs.length ): void { - if (this.tabs.find((tab) => tab.value.panel.id === panel.id)) { + if (this.tabContainer.tabs.find((tab) => tab.panel.id === panel.id)) { return; } const tab = new Tab(panel, this.accessor, this.group); @@ -355,7 +323,7 @@ export class TabsContainer } tab.setContent(panel.view.tab); - const disposable = new CompositeDisposable( + const eventsDisposable = new CompositeDisposable( tab.onDragStart((event) => { this._onTabDragStart.fire({ nativeEvent: event, panel }); }), @@ -404,7 +372,7 @@ export class TabsContainer tab.onDrop((event) => { this._onDrop.fire({ event: event.nativeEvent, - index: this.tabs.findIndex((x) => x.value === tab), + index: this.tabContainer.tabs.findIndex((x) => x === tab), }); }), tab.onWillShowOverlay((event) => { @@ -420,23 +388,12 @@ export class TabsContainer }) ); - const value: IValueDisposable = { value: tab, disposable }; + tab.addDisposables(eventsDisposable); - this.addTab(value, index); + this.tabContainer.addTab(tab, index); } public closePanel(panel: IDockviewPanel): void { this.delete(panel.id); } - - public dispose(): void { - super.dispose(); - - for (const { value, disposable } of this.tabs) { - disposable.dispose(); - value.dispose(); - } - - this.tabs = []; - } } diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsPanel.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsPanel.ts new file mode 100644 index 000000000..0dd312016 --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsPanel.ts @@ -0,0 +1,158 @@ +import { OverflowObserver } from '../../../dom'; +import { + CompositeDisposable, + Disposable, + MutableDisposable, +} from '../../../lifecycle'; +import { createExpandMoreButton } from '../../../svg'; +import { DockviewComponent } from '../../dockviewComponent'; +import { Tab } from '../tab/tab'; + +export class TabsPanel extends CompositeDisposable { + private readonly _element: HTMLElement; + private readonly _tabsList: HTMLElement; + private readonly _overflowDropdown: HTMLElement; + + private _tabs: Tab[] = []; + private _selectedIndex = -1; + + private _dropdownHandle = new MutableDisposable(); + + get element(): HTMLElement { + return this._element; + } + + get tabs(): Tab[] { + return this._tabs; + } + + constructor(private readonly accessor: DockviewComponent) { + super(); + + this._element = document.createElement('div'); + this._element.className = 'tabs-container'; + + this._tabsList = document.createElement('div'); + this._tabsList.className = 'dv-tabs-list'; + + this._overflowDropdown = document.createElement('div'); + this._overflowDropdown.className = 'dv-tabs-list-overflow'; + + this._element.appendChild(this._tabsList); + this._element.appendChild(this._overflowDropdown); + + const btn = createExpandMoreButton(); + this._overflowDropdown.appendChild(btn); + this._overflowDropdown.addEventListener('click', () => { + this.createDropdown(); + }); + + const tabsContainerOverflowObserver = new OverflowObserver( + this._tabsList + ); + + this.addDisposables( + this._dropdownHandle, + tabsContainerOverflowObserver, + tabsContainerOverflowObserver.onDidChange((e) => { + this.toggleObstructedTabs(); + }), + Disposable.from(() => { + for (const tab of this.tabs) { + tab.dispose(); + } + this._tabs = []; + }) + ); + } + + private _obstructedTabs: number = -1; + + toggleObstructedTabs(): void { + const containerScrollLeft = this._tabsList.offsetLeft; + const trueWidth = this._tabsList.clientWidth; + + const runningTotal = -containerScrollLeft; + + this._obstructedTabs = this.tabs.findIndex((tab, i) => { + const right = + tab.element.offsetLeft + + tab.element.clientWidth - + containerScrollLeft; + const isObstructed = right > trueWidth; + return isObstructed; + }); + + for (let i = 0; i < this.tabs.length; i++) { + const element = this.tabs[i].element; + + if (this._obstructedTabs !== -1 && i >= this._obstructedTabs) { + if (element.parentElement === this._tabsList) { + element.remove(); + } + } else { + if (element.parentElement !== this._tabsList) { + this._tabsList.append(element); + } + } + } + } + + private createDropdown(): void { + const dropdownContainer = document.createElement('div'); + dropdownContainer.className = 'dv-dropdown-container'; + + for (let i = this._obstructedTabs; i < this.tabs.length; i++) { + const tab = this.tabs[i]; + const dropdownItemContainer = document.createElement('div'); + dropdownItemContainer.className = 'dv-dropdown-item'; + dropdownContainer.appendChild(tab.element); + } + + this._dropdownHandle.value = Disposable.from(() => { + dropdownContainer.remove(); + }); + + this.accessor.element + .querySelector('.dv-dockview')! + .appendChild(dropdownContainer); + } + + isActive(tab: Tab): boolean { + return ( + this._selectedIndex > -1 && this.tabs[this._selectedIndex] === tab + ); + } + + removeTab(id: string): void { + const index = this.tabs.findIndex((tab) => tab.panel.id === id); + + const tabToRemove = this.tabs.splice(index, 1)[0]; + + tabToRemove.dispose(); + tabToRemove.element.remove(); + } + + addTab(tab: Tab, index = this._tabs.length): void { + if (index < 0 || index > this._tabs.length) { + throw new Error('invalid location'); + } + + this._tabsList.insertBefore( + tab.element, + this._tabsList.children[index] + ); + + this._tabs = [ + ...this._tabs.slice(0, index), + tab, + ...this._tabs.slice(index), + ]; + + if (this._selectedIndex < 0) { + this._selectedIndex = index; + } + + this.toggleObstructedTabs(); + } +} diff --git a/packages/dockview-core/src/dom.ts b/packages/dockview-core/src/dom.ts index c25b12450..883d816cd 100644 --- a/packages/dockview-core/src/dom.ts +++ b/packages/dockview-core/src/dom.ts @@ -6,6 +6,36 @@ import { } from './events'; import { IDisposable, CompositeDisposable } from './lifecycle'; +export interface OverflowEvent { + hasScrollX: boolean; + hasScrollY: boolean; +} + +export class OverflowObserver extends CompositeDisposable { + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + private _value: OverflowEvent | null = null; + + constructor(el: HTMLElement) { + super(); + + this.addDisposables( + this._onDidChange, + watchElementResize(el, (entry) => { + const hasScrollX = + entry.target.scrollWidth > entry.target.clientWidth; + + const hasScrollY = + entry.target.scrollHeight > entry.target.clientHeight; + + this._value = { hasScrollX, hasScrollY }; + this._onDidChange.fire(this._value); + }) + ); + } +} + export function watchElementResize( element: HTMLElement, cb: (entry: ResizeObserverEntry) => void