diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96a4de786..8f11fa57c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,7 +27,7 @@ jobs: - run: npm run build - run: npm run test:cov - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v4.1.0 + uses: sonarsource/sonarqube-scan-action@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts b/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts index 79e21b88a..2a43a1796 100644 --- a/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts +++ b/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts @@ -1,11 +1,12 @@ import { IDockviewPanelModel } from '../../dockview/dockviewPanelModel'; import { DockviewGroupPanel } from '../../dockview/dockviewGroupPanel'; import { - GroupPanelPartInitParameters, + TabPartInitParameters, IContentRenderer, ITabRenderer, } from '../../dockview/types'; import { PanelUpdateEvent } from '../../panel/types'; +import { TabLocation } from '../../dockview/framework'; export class DockviewPanelModelMock implements IDockviewPanelModel { constructor( @@ -17,8 +18,11 @@ export class DockviewPanelModelMock implements IDockviewPanelModel { // } + createTabRenderer(tabLocation: TabLocation): ITabRenderer { + return this.tab; + } - init(params: GroupPanelPartInitParameters): void { + init(params: TabPartInitParameters): void { // } diff --git a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts index 0bc24eaa5..d4255ab22 100644 --- a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts +++ b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts @@ -10,6 +10,7 @@ describe('groupPanelApi', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const panelMock = jest.fn(() => { @@ -50,6 +51,7 @@ describe('groupPanelApi', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupViewPanel = new DockviewGroupPanel( @@ -82,6 +84,7 @@ describe('groupPanelApi', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupViewPanel = new DockviewGroupPanel( diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts index bae8daa8d..bb79949cf 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts @@ -18,6 +18,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -71,6 +72,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const dropTargetContainer = document.createElement('div'); @@ -140,6 +142,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -203,6 +206,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -266,6 +270,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -334,6 +339,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -398,6 +404,7 @@ describe('tabsContainer', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -464,6 +471,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), doSetGroupActive: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -520,6 +528,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), doSetGroupActive: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -571,6 +580,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -627,6 +637,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -694,6 +705,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -761,6 +773,7 @@ describe('tabsContainer', () => { element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupPanelMock = jest.fn(() => { @@ -824,6 +837,7 @@ describe('tabsContainer', () => { const cut = new TabsContainer( fromPartial({ options: {}, + onDidOptionsChange: jest.fn(), }), fromPartial({}) ); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 9df09dc2f..d84a723e4 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -133,11 +133,15 @@ describe('dockviewComponent', () => { }, className: 'test-a test-b', }); - expect(dockview.element.className).toBe('test-a test-b dockview-theme-abyss'); + expect(dockview.element.className).toBe( + 'test-a test-b dockview-theme-abyss' + ); dockview.updateOptions({ className: 'test-b test-c' }); - expect(dockview.element.className).toBe('dockview-theme-abyss test-b test-c'); + expect(dockview.element.className).toBe( + 'dockview-theme-abyss test-b test-c' + ); }); describe('memory leakage', () => { @@ -2453,17 +2457,17 @@ describe('dockviewComponent', () => { const group = dockview.getGroupPanel('panel2')!.api.group; const viewQuery = group.element.querySelectorAll( - '.dv-groupview > .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab' + '.dv-groupview > .dv-tabs-and-actions-container > .dv-scrollable > .dv-tabs-container > .dv-tab' ); expect(viewQuery.length).toBe(2); const viewQuery2 = group.element.querySelectorAll( - '.dv-groupview > .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab > .dv-default-tab' + '.dv-groupview > .dv-tabs-and-actions-container > .dv-scrollable > .dv-tabs-container > .dv-tab > .dv-default-tab' ); expect(viewQuery2.length).toBe(1); const viewQuery3 = group.element.querySelectorAll( - '.dv-groupview > .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab > .panel-tab-part-panel2' + '.dv-groupview > .dv-tabs-and-actions-container > .dv-scrollable > .dv-tabs-container > .dv-tab > .panel-tab-part-panel2' ); expect(viewQuery3.length).toBe(1); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanel.spec.ts index eb2ab5320..1a01b1d30 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanel.spec.ts @@ -16,6 +16,7 @@ describe('dockviewGroupPanel', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), }); const options = fromPartial({}); const cut = new DockviewGroupPanel(accessor, 'test_id', options); @@ -39,6 +40,7 @@ describe('dockviewGroupPanel', () => { detatch: jest.fn(), }, doSetGroupActive: jest.fn(), + onDidOptionsChange: jest.fn(), }); const options = fromPartial({}); @@ -81,6 +83,7 @@ describe('dockviewGroupPanel', () => { detatch: jest.fn(), }), options: {}, + onDidOptionsChange: jest.fn(), }); const options = fromPartial({}); const cut = new DockviewGroupPanel(accessor, 'test_id', options); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 19b811c4f..6e24cd53f 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -24,6 +24,7 @@ import { createOffsetDragOverEvent } from '../__test_utils__/utils'; import { OverlayRenderContainer } from '../../overlay/overlayRenderContainer'; import { Emitter } from '../../events'; import { fromPartial } from '@total-typescript/shoehorn'; +import { TabLocation } from '../../dockview/framework'; enum GroupChangeKind2 { ADD_PANEL, @@ -36,12 +37,16 @@ class TestModel implements IDockviewPanelModel { readonly contentComponent: string; readonly tab: ITabRenderer; - constructor(id: string) { + constructor(readonly id: string) { this.content = new TestHeaderPart(id); this.contentComponent = id; this.tab = new TestContentPart(id); } + createTabRenderer(tabLocation: TabLocation): ITabRenderer { + return new TestHeaderPart(this.id); + } + update(event: PanelUpdateEvent): void { // } @@ -265,6 +270,7 @@ describe('dockviewGroupPanelModel', () => { document.createElement('div'), fromPartial({}) ), + onDidOptionsChange: () => ({ dispose: jest.fn() }), }); groupview = new DockviewGroupPanel(dockview, 'groupview-1', options); @@ -646,6 +652,7 @@ describe('dockviewGroupPanelModel', () => { getPanel: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -708,6 +715,7 @@ describe('dockviewGroupPanelModel', () => { getPanel: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -804,6 +812,7 @@ describe('dockviewGroupPanelModel', () => { document.createElement('div'), fromPartial({}) ), + onDidOptionsChange: jest.fn(), }); const groupView = fromPartial({ @@ -870,6 +879,7 @@ describe('dockviewGroupPanelModel', () => { document.createElement('div'), fromPartial({}) ), + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -943,6 +953,7 @@ describe('dockviewGroupPanelModel', () => { document.createElement('div'), fromPartial({}) ), + onDidOptionsChange: jest.fn(), }); const groupviewMock = jest.fn, []>( diff --git a/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts index cfae5ea6b..18ed7d1d6 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts @@ -8,6 +8,7 @@ describe('gridviewPanel', () => { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), options: {}, + onDidOptionsChange: jest.fn(), } as any; }); diff --git a/packages/dockview-core/src/dockview/components/popupService.ts b/packages/dockview-core/src/dockview/components/popupService.ts new file mode 100644 index 000000000..58f0b9853 --- /dev/null +++ b/packages/dockview-core/src/dockview/components/popupService.ts @@ -0,0 +1,82 @@ +import { addDisposableWindowListener } from '../../events'; +import { + CompositeDisposable, + Disposable, + MutableDisposable, +} from '../../lifecycle'; + +export class PopupService extends CompositeDisposable { + private readonly _element: HTMLElement; + private _active: HTMLElement | null = null; + private readonly _activeDisposable = new MutableDisposable(); + + constructor(private readonly root: HTMLElement) { + super(); + + this._element = document.createElement('div'); + this._element.className = 'dv-popover-anchor'; + this._element.style.position = 'relative'; + + this.root.prepend(this._element); + + this.addDisposables( + Disposable.from(() => { + this.close(); + }), + this._activeDisposable + ); + } + + openPopover( + element: HTMLElement, + position: { x: number; y: number } + ): void { + this.close(); + + const wrapper = document.createElement('div'); + wrapper.style.position = 'absolute'; + wrapper.style.zIndex = '99'; + wrapper.appendChild(element); + + const anchorBox = this._element.getBoundingClientRect(); + const offsetX = anchorBox.left; + const offsetY = anchorBox.top; + + wrapper.style.top = `${position.y - offsetY}px`; + wrapper.style.left = `${position.x - offsetX}px`; + + this._element.appendChild(wrapper); + + this._active = wrapper; + + this._activeDisposable.value = new CompositeDisposable( + addDisposableWindowListener(window, 'pointerdown', (event) => { + const target = event.target; + + if (!(target instanceof HTMLElement)) { + return; + } + + let el: HTMLElement | null = target; + + while (el && el !== wrapper) { + el = el?.parentElement ?? null; + } + + if (el) { + return; // clicked within popover + } + + this.close(); + }) + ); + } + + close(): void { + if (this._active) { + this._active.remove(); + this._activeDisposable.dispose(); + this._active = null; + } + } +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.scss b/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.scss new file mode 100644 index 000000000..2d87ce7ca --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.scss @@ -0,0 +1,19 @@ +.dv-tabs-overflow-dropdown-default { + height: 100%; + color: var(--dv-activegroup-hiddenpanel-tab-color); + + margin: var(--dv-tab-margin); + display: flex; + align-items: center; + flex-shrink: 0; + padding: 0.25rem 0.5rem; + cursor: pointer; + + > span { + padding-left: 0.25rem; + } + + > svg { + transform: rotate(90deg); + } +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.ts b/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.ts new file mode 100644 index 000000000..1e27c661e --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.ts @@ -0,0 +1,25 @@ +import { createChevronRightButton } from '../../../svg'; + +export type DropdownElement = { + element: HTMLElement; + update: (params: { tabs: number }) => void; + dispose?: () => void; +}; + +export function createDropdownElementHandle(): DropdownElement { + const el = document.createElement('div'); + el.className = 'dv-tabs-overflow-dropdown-default'; + + const text = document.createElement('span'); + text.textContent = ``; + const icon = createChevronRightButton(); + el.appendChild(icon); + el.appendChild(text); + + return { + element: el, + update: (params: { tabs: number }) => { + text.textContent = `${params.tabs}`; + }, + }; +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss new file mode 100644 index 000000000..279659fac --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss @@ -0,0 +1,92 @@ +.dv-tabs-container { + display: flex; + height: 100%; + overflow: hidden; + scrollbar-width: thin; // firefox + + &.dv-horizontal { + .dv-tabs-container { + .dv-tab { + &:last-child { + margin-right: 0; + } + + &:not(:nth-last-child(1)) { + margin-left: 0; + } + + &:not(:first-child)::before { + content: ' '; + position: absolute; + top: 0; + left: 0; + z-index: 5; + pointer-events: none; + background-color: var(--dv-tab-divider-color); + width: 1px; + height: 100%; + } + } + } + } + + &::-webkit-scrollbar { + height: 3px; + } + + /* Track */ + &::-webkit-scrollbar-track { + background: transparent; + } + + /* Handle */ + &::-webkit-scrollbar-thumb { + background: var(--dv-tabs-container-scrollbar-color); + } + + .dv-tab { + -webkit-user-drag: element; + outline: none; + padding: 0.25rem 0.5rem; + cursor: pointer; + position: relative; + box-sizing: border-box; + font-size: var(--dv-tab-font-size); + margin: var(--dv-tab-margin); + } +} + +.dv-tabs-overflow-container { + flex-direction: column; + height: unset; + border: 1px solid var(--dv-tab-divider-color); + background-color: var(--dv-group-view-background-color); + + .dv-tab { + -webkit-user-drag: element; + outline: none; + padding: 0.25rem 0.5rem; + cursor: pointer; + position: relative; + box-sizing: border-box; + font-size: var(--dv-tab-font-size); + margin: var(--dv-tab-margin); + + &:not(:last-child) { + border-bottom: 1px solid var(--dv-tab-divider-color); + } + } + + .dv-active-tab { + background-color: var( + --dv-activegroup-visiblepanel-tab-background-color + ); + color: var(--dv-activegroup-visiblepanel-tab-color); + } + .dv-inactive-tab { + background-color: var( + --dv-activegroup-hiddenpanel-tab-background-color + ); + color: var(--dv-activegroup-hiddenpanel-tab-color); + } +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts new file mode 100644 index 000000000..b8a6d5499 --- /dev/null +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -0,0 +1,297 @@ +import { getPanelData } from '../../../dnd/dataTransfer'; +import { + isChildEntirelyVisibleWithinParent, + OverflowObserver, +} from '../../../dom'; +import { addDisposableListener, Emitter, Event } from '../../../events'; +import { + CompositeDisposable, + Disposable, + IValueDisposable, + MutableDisposable, +} from '../../../lifecycle'; +import { Scrollbar } from '../../../scrollbar'; +import { DockviewComponent } from '../../dockviewComponent'; +import { DockviewGroupPanel } from '../../dockviewGroupPanel'; +import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel'; +import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; +import { Tab } from '../tab/tab'; +import { TabDragEvent, TabDropIndexEvent } from './tabsContainer'; + +export class Tabs extends CompositeDisposable { + private readonly _element: HTMLElement; + private readonly _tabsList: HTMLElement; + private readonly _observerDisposable = new MutableDisposable(); + + private _tabs: IValueDisposable[] = []; + private selectedIndex = -1; + private _showTabsOverflowControl = false; + + private readonly _onTabDragStart = new Emitter(); + readonly onTabDragStart: Event = this._onTabDragStart.event; + + private readonly _onDrop = new Emitter(); + readonly onDrop: Event = this._onDrop.event; + + private readonly _onWillShowOverlay = + new Emitter(); + readonly onWillShowOverlay: Event = + this._onWillShowOverlay.event; + + private readonly _onOverflowTabsChange = new Emitter<{ + tabs: string[]; + reset: boolean; + }>(); + readonly onOverflowTabsChange = this._onOverflowTabsChange.event; + + get showTabsOverflowControl(): boolean { + return this._showTabsOverflowControl; + } + + set showTabsOverflowControl(value: boolean) { + if (this._showTabsOverflowControl == value) { + return; + } + + this._showTabsOverflowControl = value; + + if (value) { + const observer = new OverflowObserver(this._tabsList); + + this._observerDisposable.value = new CompositeDisposable( + observer, + observer.onDidChange((event) => { + const hasOverflow = event.hasScrollX || event.hasScrollY; + this.toggleDropdown({ reset: !hasOverflow }); + }), + addDisposableListener(this._tabsList, 'scroll', () => { + this.toggleDropdown({ reset: false }); + }) + ); + } + } + + get element(): HTMLElement { + return this._element; + } + + get panels(): string[] { + return this._tabs.map((_) => _.value.panel.id); + } + + get size(): number { + return this._tabs.length; + } + + get tabs(): Tab[] { + return this._tabs.map((_) => _.value); + } + + constructor( + private readonly group: DockviewGroupPanel, + private readonly accessor: DockviewComponent, + options: { + showTabsOverflowControl: boolean; + } + ) { + super(); + + this._tabsList = document.createElement('div'); + this._tabsList.className = 'dv-tabs-container dv-horizontal'; + + this.showTabsOverflowControl = options.showTabsOverflowControl; + + const scrollbar = new Scrollbar(this._tabsList); + this._element = scrollbar.element; + + this.addDisposables( + this._onOverflowTabsChange, + this._observerDisposable, + scrollbar, + this._onWillShowOverlay, + this._onDrop, + this._onTabDragStart, + addDisposableListener(this.element, 'pointerdown', (event) => { + if (event.defaultPrevented) { + return; + } + + const isLeftClick = event.button === 0; + + if (isLeftClick) { + this.accessor.doSetGroupActive(this.group); + } + }), + Disposable.from(() => { + for (const { value, disposable } of this._tabs) { + disposable.dispose(); + value.dispose(); + } + + this._tabs = []; + }) + ); + } + + indexOf(id: string): number { + return this._tabs.findIndex((tab) => tab.value.panel.id === id); + } + + isActive(tab: Tab): boolean { + return ( + this.selectedIndex > -1 && + this._tabs[this.selectedIndex].value === tab + ); + } + + setActivePanel(panel: IDockviewPanel): void { + let runningWidth = 0; + + for (const tab of this._tabs) { + const isActivePanel = panel.id === tab.value.panel.id; + tab.value.setActive(isActivePanel); + + if (isActivePanel) { + const element = tab.value.element; + const parentElement = element.parentElement!; + + if ( + runningWidth < parentElement.scrollLeft || + runningWidth + element.clientWidth > + parentElement.scrollLeft + parentElement.clientWidth + ) { + parentElement.scrollLeft = runningWidth; + } + } + + runningWidth += tab.value.element.clientWidth; + } + } + + openPanel(panel: IDockviewPanel, index: number = this._tabs.length): void { + if (this._tabs.find((tab) => tab.value.panel.id === panel.id)) { + return; + } + const tab = new Tab(panel, this.accessor, this.group); + tab.setContent(panel.view.tab); + + const disposable = new CompositeDisposable( + tab.onDragStart((event) => { + this._onTabDragStart.fire({ nativeEvent: event, panel }); + }), + tab.onPointerDown((event) => { + if (event.defaultPrevented) { + return; + } + + const isFloatingGroupsEnabled = + !this.accessor.options.disableFloatingGroups; + + const isFloatingWithOnePanel = + this.group.api.location.type === 'floating' && + this.size === 1; + + if ( + isFloatingGroupsEnabled && + !isFloatingWithOnePanel && + event.shiftKey + ) { + event.preventDefault(); + + const panel = this.accessor.getGroupPanel(tab.panel.id); + + const { top, left } = tab.element.getBoundingClientRect(); + const { top: rootTop, left: rootLeft } = + this.accessor.element.getBoundingClientRect(); + + this.accessor.addFloatingGroup(panel as DockviewPanel, { + x: left - rootLeft, + y: top - rootTop, + inDragMode: true, + }); + return; + } + + switch (event.button) { + case 0: // left click or touch + if (this.group.activePanel !== panel) { + this.group.model.openPanel(panel); + } + break; + } + }), + tab.onDrop((event) => { + this._onDrop.fire({ + event: event.nativeEvent, + index: this._tabs.findIndex((x) => x.value === tab), + }); + }), + tab.onWillShowOverlay((event) => { + this._onWillShowOverlay.fire( + new WillShowOverlayLocationEvent(event, { + kind: 'tab', + panel: this.group.activePanel, + api: this.accessor.api, + group: this.group, + getData: getPanelData, + }) + ); + }) + ); + + const value: IValueDisposable = { value: tab, disposable }; + + this.addTab(value, index); + } + + delete(id: string): void { + const index = this.indexOf(id); + const tabToRemove = this._tabs.splice(index, 1)[0]; + + const { value, disposable } = tabToRemove; + + disposable.dispose(); + value.dispose(); + value.element.remove(); + } + + private addTab( + tab: IValueDisposable, + index: number = this._tabs.length + ): void { + if (index < 0 || index > this._tabs.length) { + throw new Error('invalid location'); + } + + this._tabsList.insertBefore( + tab.value.element, + this._tabsList.children[index] + ); + + this._tabs = [ + ...this._tabs.slice(0, index), + tab, + ...this._tabs.slice(index), + ]; + + if (this.selectedIndex < 0) { + this.selectedIndex = index; + } + } + + private toggleDropdown(options: { reset: boolean }): void { + const tabs = options.reset + ? [] + : this._tabs + .filter( + (tab) => + !isChildEntirelyVisibleWithinParent( + tab.value.element, + this._tabsList + ) + ) + .map((x) => x.value.panel.id); + + this._onOverflowTabsChange.fire({ tabs, reset: options.reset }); + } +} diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss index 86777ec9e..1ed918a1c 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss @@ -26,56 +26,7 @@ cursor: grab; } - .dv-tabs-container { + .dv-right-actions-container { display: flex; - overflow-x: overlay; - overflow-y: hidden; - - scrollbar-width: thin; // firefox - - &::-webkit-scrollbar { - height: 3px; - } - - /* Track */ - &::-webkit-scrollbar-track { - background: transparent; - } - - /* Handle */ - &::-webkit-scrollbar-thumb { - background: var(--dv-tabs-container-scrollbar-color); - } - - .dv-tab { - -webkit-user-drag: element; - outline: none; - padding: 0.25rem 0.5rem; - cursor: pointer; - position: relative; - box-sizing: border-box; - font-size: var(-dv-tab-font-size); - margin: var(--dv-tab-margin); - - &:first-child { - margin-right: 0; - } - - &:not(:nth-last-child(1)) { - margin-left: 0; - } - - &:not(:first-child)::before { - content: ' '; - position: absolute; - top: 0; - left: 0; - z-index: 5; - pointer-events: none; - background-color: var(--dv-tab-divider-color); - width: 1px; - height: 100%; - } - } } } diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index 162c6a336..e02533611 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -1,20 +1,23 @@ import { IDisposable, CompositeDisposable, - IValueDisposable, + Disposable, + MutableDisposable, } from '../../../lifecycle'; import { addDisposableListener, Emitter, Event } from '../../../events'; import { Tab } from '../tab/tab'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { VoidContainer } from './voidContainer'; import { toggleClass } from '../../../dom'; -import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; +import { IDockviewPanel } from '../../dockviewPanel'; import { DockviewComponent } from '../../dockviewComponent'; -import { - DockviewGroupPanelModel, - WillShowOverlayLocationEvent, -} from '../../dockviewGroupPanelModel'; +import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel'; import { getPanelData } from '../../../dnd/dataTransfer'; +import { Tabs } from './tabs'; +import { + createDropdownElementHandle, + DropdownElement, +} from './tabOverflowControl'; export interface TabDropIndexEvent { readonly event: DragEvent; @@ -59,25 +62,28 @@ export class TabsContainer implements ITabsContainer { private readonly _element: HTMLElement; - private readonly tabContainer: HTMLElement; + private readonly tabs: Tabs; 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; private _hidden = false; + private dropdownPart: DropdownElement | null = null; + private _overflowTabs: string[] = []; + private readonly _dropdownDisposable = new MutableDisposable(); + private readonly _onDrop = new Emitter(); readonly onDrop: Event = this._onDrop.event; - private readonly _onTabDragStart = new Emitter(); - readonly onTabDragStart: Event = this._onTabDragStart.event; + get onTabDragStart(): Event { + return this.tabs.onTabDragStart; + } private readonly _onGroupDragStart = new Emitter(); readonly onGroupDragStart: Event = @@ -89,11 +95,11 @@ export class TabsContainer this._onWillShowOverlay.event; get panels(): string[] { - return this.tabs.map((_) => _.value.panel.id); + return this.tabs.panels; } get size(): number { - return this.tabs.length; + return this.tabs.size; } get hidden(): boolean { @@ -105,6 +111,116 @@ export class TabsContainer this.element.style.display = value ? 'none' : ''; } + get element(): HTMLElement { + return this._element; + } + + constructor( + private readonly accessor: DockviewComponent, + private readonly group: DockviewGroupPanel + ) { + super(); + + this._element = document.createElement('div'); + this._element.className = 'dv-tabs-and-actions-container'; + + toggleClass( + this._element, + 'dv-full-width-single-tab', + this.accessor.options.singleTabMode === 'fullwidth' + ); + + this.rightActionsContainer = document.createElement('div'); + this.rightActionsContainer.className = 'dv-right-actions-container'; + + this.leftActionsContainer = document.createElement('div'); + this.leftActionsContainer.className = 'dv-left-actions-container'; + + this.preActionsContainer = document.createElement('div'); + this.preActionsContainer.className = 'dv-pre-actions-container'; + + this.tabs = new Tabs(group, accessor, { + showTabsOverflowControl: !accessor.options.disableTabsOverflowList, + }); + + this.voidContainer = new VoidContainer(this.accessor, this.group); + + this._element.appendChild(this.preActionsContainer); + this._element.appendChild(this.tabs.element); + this._element.appendChild(this.leftActionsContainer); + this._element.appendChild(this.voidContainer.element); + this._element.appendChild(this.rightActionsContainer); + + this.addDisposables( + accessor.onDidOptionsChange(() => { + this.tabs.showTabsOverflowControl = + !accessor.options.disableTabsOverflowList; + }), + this.tabs.onOverflowTabsChange((event) => { + this.toggleDropdown(event); + }), + this.tabs, + this._onWillShowOverlay, + this._onDrop, + this._onGroupDragStart, + this.voidContainer, + this.voidContainer.onDragStart((event) => { + this._onGroupDragStart.fire({ + nativeEvent: event, + group: this.group, + }); + }), + this.voidContainer.onDrop((event) => { + this._onDrop.fire({ + event: event.nativeEvent, + index: this.tabs.size, + }); + }), + this.voidContainer.onWillShowOverlay((event) => { + this._onWillShowOverlay.fire( + new WillShowOverlayLocationEvent(event, { + kind: 'header_space', + panel: this.group.activePanel, + api: this.accessor.api, + group: this.group, + getData: getPanelData, + }) + ); + }), + addDisposableListener( + this.voidContainer.element, + 'pointerdown', + (event) => { + if (event.defaultPrevented) { + return; + } + + const isFloatingGroupsEnabled = + !this.accessor.options.disableFloatingGroups; + + if ( + isFloatingGroupsEnabled && + event.shiftKey && + this.group.api.location.type !== 'floating' + ) { + event.preventDefault(); + + const { top, left } = + this.element.getBoundingClientRect(); + const { top: rootTop, left: rootLeft } = + this.accessor.element.getBoundingClientRect(); + + this.accessor.addFloatingGroup(this.group, { + x: left - rootLeft + 20, + y: top - rootTop + 20, + inDragMode: true, + }); + } + } + ) + ); + } + show(): void { if (!this.hidden) { this.element.style.display = ''; @@ -157,276 +273,124 @@ export class TabsContainer } } - get element(): HTMLElement { - return this._element; + isActive(tab: Tab): boolean { + return this.tabs.isActive(tab); } - public isActive(tab: Tab): boolean { - return ( - this.selectedIndex > -1 && - this.tabs[this.selectedIndex].value === tab - ); + indexOf(id: string): number { + return this.tabs.indexOf(id); } - public indexOf(id: string): number { - return this.tabs.findIndex((tab) => tab.value.panel.id === id); - } - - constructor( - private readonly accessor: DockviewComponent, - private readonly group: DockviewGroupPanel - ) { - super(); - - this._element = document.createElement('div'); - this._element.className = 'dv-tabs-and-actions-container'; - - toggleClass( - this._element, - 'dv-full-width-single-tab', - this.accessor.options.singleTabMode === 'fullwidth' - ); - - this.rightActionsContainer = document.createElement('div'); - this.rightActionsContainer.className = 'dv-right-actions-container'; - - this.leftActionsContainer = document.createElement('div'); - this.leftActionsContainer.className = 'dv-left-actions-container'; - - this.preActionsContainer = document.createElement('div'); - this.preActionsContainer.className = 'dv-pre-actions-container'; - - this.tabContainer = document.createElement('div'); - this.tabContainer.className = 'dv-tabs-container'; - - this.voidContainer = new VoidContainer(this.accessor, this.group); - - this._element.appendChild(this.preActionsContainer); - this._element.appendChild(this.tabContainer); - this._element.appendChild(this.leftActionsContainer); - this._element.appendChild(this.voidContainer.element); - this._element.appendChild(this.rightActionsContainer); - - this.addDisposables( - this._onWillShowOverlay, - this._onDrop, - this._onTabDragStart, - this._onGroupDragStart, - this.voidContainer, - this.voidContainer.onDragStart((event) => { - this._onGroupDragStart.fire({ - nativeEvent: event, - group: this.group, - }); - }), - this.voidContainer.onDrop((event) => { - this._onDrop.fire({ - event: event.nativeEvent, - index: this.tabs.length, - }); - }), - this.voidContainer.onWillShowOverlay((event) => { - this._onWillShowOverlay.fire( - new WillShowOverlayLocationEvent(event, { - kind: 'header_space', - panel: this.group.activePanel, - api: this.accessor.api, - group: this.group, - getData: getPanelData, - }) - ); - }), - addDisposableListener( - this.voidContainer.element, - 'pointerdown', - (event) => { - const isFloatingGroupsEnabled = - !this.accessor.options.disableFloatingGroups; - - if ( - isFloatingGroupsEnabled && - event.shiftKey && - this.group.api.location.type !== 'floating' - ) { - event.preventDefault(); - - const { top, left } = - this.element.getBoundingClientRect(); - const { top: rootTop, left: rootLeft } = - this.accessor.element.getBoundingClientRect(); - - this.accessor.addFloatingGroup(this.group, { - x: left - rootLeft + 20, - y: top - rootTop + 20, - inDragMode: true, - }); - } - } - ), - addDisposableListener(this.tabContainer, 'pointerdown', (event) => { - if (event.defaultPrevented) { - return; - } - - const isLeftClick = event.button === 0; - - if (isLeftClick) { - this.accessor.doSetGroupActive(this.group); - } - }) - ); - } - - public setActive(_isGroupActive: boolean) { + setActive(_isGroupActive: boolean) { // noop } - public delete(id: string): void { - const index = this.tabs.findIndex((tab) => tab.value.panel.id === id); - - const tabToRemove = this.tabs.splice(index, 1)[0]; - - if (!tabToRemove) { - throw new Error(`dockview: Tab not found`); - } - - const { value, disposable } = tabToRemove; - - disposable.dispose(); - value.dispose(); - value.element.remove(); - + delete(id: string): void { + this.tabs.delete(id); this.updateClassnames(); } - public setActivePanel(panel: IDockviewPanel): void { - this.tabs.forEach((tab) => { - const isActivePanel = panel.id === tab.value.panel.id; - tab.value.setActive(isActivePanel); - }); + setActivePanel(panel: IDockviewPanel): void { + this.tabs.setActivePanel(panel); } - public openPanel( - panel: IDockviewPanel, - index: number = this.tabs.length - ): void { - if (this.tabs.find((tab) => tab.value.panel.id === panel.id)) { - return; - } - const tab = new Tab(panel, this.accessor, this.group); - tab.setContent(panel.view.tab); - - const disposable = new CompositeDisposable( - tab.onDragStart((event) => { - this._onTabDragStart.fire({ nativeEvent: event, panel }); - }), - tab.onPointerDown((event) => { - if (event.defaultPrevented) { - return; - } - - const isFloatingGroupsEnabled = - !this.accessor.options.disableFloatingGroups; - - const isFloatingWithOnePanel = - this.group.api.location.type === 'floating' && - this.size === 1; - - if ( - isFloatingGroupsEnabled && - !isFloatingWithOnePanel && - event.shiftKey - ) { - event.preventDefault(); - - const panel = this.accessor.getGroupPanel(tab.panel.id); - - const { top, left } = tab.element.getBoundingClientRect(); - const { top: rootTop, left: rootLeft } = - this.accessor.element.getBoundingClientRect(); - - this.accessor.addFloatingGroup(panel as DockviewPanel, { - x: left - rootLeft, - y: top - rootTop, - inDragMode: true, - }); - return; - } - - switch (event.button) { - case 0: // left click or touch - if (this.group.activePanel !== panel) { - this.group.model.openPanel(panel); - } - break; - } - }), - tab.onDrop((event) => { - this._onDrop.fire({ - event: event.nativeEvent, - index: this.tabs.findIndex((x) => x.value === tab), - }); - }), - tab.onWillShowOverlay((event) => { - this._onWillShowOverlay.fire( - new WillShowOverlayLocationEvent(event, { - kind: 'tab', - panel: this.group.activePanel, - api: this.accessor.api, - group: this.group, - getData: getPanelData, - }) - ); - }) - ); - - const value: IValueDisposable = { value: tab, disposable }; - - this.addTab(value, index); + openPanel(panel: IDockviewPanel, index: number = this.tabs.size): void { + this.tabs.openPanel(panel, index); + this.updateClassnames(); } - public closePanel(panel: IDockviewPanel): void { + 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 = []; - } - - private addTab( - tab: IValueDisposable, - index: number = this.tabs.length - ): void { - if (index < 0 || index > this.tabs.length) { - throw new Error('invalid location'); - } - - this.tabContainer.insertBefore( - tab.value.element, - this.tabContainer.children[index] - ); - - this.tabs = [ - ...this.tabs.slice(0, index), - tab, - ...this.tabs.slice(index), - ]; - - if (this.selectedIndex < 0) { - this.selectedIndex = index; - } - - this.updateClassnames(); - } - private updateClassnames(): void { toggleClass(this._element, 'dv-single-tab', this.size === 1); } + + private toggleDropdown(options: { tabs: string[]; reset: boolean }): void { + const tabs = options.reset ? [] : options.tabs; + this._overflowTabs = tabs; + + if (this._overflowTabs.length > 0 && this.dropdownPart) { + this.dropdownPart.update({ tabs: tabs.length }); + return; + } + + if (this._overflowTabs.length === 0) { + this._dropdownDisposable.dispose(); + return; + } + + const root = document.createElement('div'); + root.className = 'dv-tabs-overflow-dropdown-root'; + + const part = createDropdownElementHandle(); + part.update({ tabs: tabs.length }); + + this.dropdownPart = part; + + root.appendChild(part.element); + this.rightActionsContainer.prepend(root); + + this._dropdownDisposable.value = new CompositeDisposable( + Disposable.from(() => { + root.remove(); + this.dropdownPart?.dispose?.(); + this.dropdownPart = null; + }), + addDisposableListener( + root, + 'pointerdown', + (event) => { + event.preventDefault(); + }, + { capture: true } + ), + addDisposableListener(root, 'click', (event) => { + const el = document.createElement('div'); + el.style.overflow = 'auto'; + el.className = 'dv-tabs-overflow-container'; + + for (const tab of this.tabs.tabs.filter((tab) => + this._overflowTabs.includes(tab.panel.id) + )) { + const panelObject = this.group.panels.find( + (panel) => panel === tab.panel + )!; + + const tabComponent = + panelObject.view.createTabRenderer('headerOverflow'); + + const child = tabComponent.element; + + const wrapper = document.createElement('div'); + toggleClass(wrapper, 'dv-tab', true); + toggleClass( + wrapper, + 'dv-active-tab', + panelObject.api.isActive + ); + toggleClass( + wrapper, + 'dv-inactive-tab', + !panelObject.api.isActive + ); + + wrapper.addEventListener('mousedown', () => { + this.accessor.popupService.close(); + tab.element.scrollIntoView(); + tab.panel.api.setActive(); + }); + wrapper.appendChild(child); + + el.appendChild(wrapper); + } + + this.accessor.popupService.openPopover(el, { + x: event.clientX, + y: event.clientY, + }); + }) + ); + } } diff --git a/packages/dockview-core/src/dockview/dockviewComponent.scss b/packages/dockview-core/src/dockview/dockviewComponent.scss index 386bf2a82..3340e976a 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -18,7 +18,10 @@ .dv-groupview { &.dv-active-group { - > .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab { + > .dv-tabs-and-actions-container + > .dv-scrollable + > .dv-tabs-container + > .dv-tab { &.dv-active-tab { background-color: var( --dv-activegroup-visiblepanel-tab-background-color @@ -34,7 +37,10 @@ } } &.dv-inactive-group { - > .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab { + > .dv-tabs-and-actions-container + > .dv-scrollable + > .dv-tabs-container + > .dv-tab { &.dv-active-tab { background-color: var( --dv-inactivegroup-visiblepanel-tab-background-color diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 83489f6b8..e9abdf237 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -75,8 +75,9 @@ import { } from '../overlay/overlayRenderContainer'; import { PopoutWindow } from '../popoutWindow'; import { StrictEventsSequencing } from './strictEventsSequencing'; +import { PopupService } from './components/popupService'; import { DropTargetAnchorContainer } from '../dnd/dropTargetAnchorContainer'; -import { DockviewTheme, themeAbyss } from './theme'; +import { themeAbyss } from './theme'; const DEFAULT_ROOT_OVERLAY_MODEL: DroptargetOverlayModel = { activationSize: { type: 'pixels', value: 10 }, @@ -263,6 +264,7 @@ export class DockviewComponent private readonly _themeClassnames: Classnames; readonly overlayRenderContainer: OverlayRenderContainer; + readonly popupService: PopupService; readonly rootDropTargetContainer: DropTargetAnchorContainer; private readonly _onWillDragPanel = new Emitter(); @@ -328,6 +330,9 @@ export class DockviewComponent readonly onDidAddGroup: Event = this._onDidAddGroup.event; + private readonly _onDidOptionsChange = new Emitter(); + readonly onDidOptionsChange: Event = this._onDidOptionsChange.event; + private readonly _onDidActiveGroupChange = new Emitter< DockviewGroupPanel | undefined >(); @@ -392,6 +397,8 @@ export class DockviewComponent className: options.className, }); + this.popupService = new PopupService(this.element); + this.updateDropTargetModel(options); this._themeClassnames = new Classnames(this.element); @@ -430,6 +437,7 @@ export class DockviewComponent this._onDidActiveGroupChange, this._onUnhandledDragOverEvent, this._onDidMaximizedGroupChange, + this._onDidOptionsChange, this.onDidViewVisibilityChangeMicroTaskQueue(() => { this.updateWatermark(); }), diff --git a/packages/dockview-core/src/dockview/dockviewPanelModel.ts b/packages/dockview-core/src/dockview/dockviewPanelModel.ts index 950b4d577..903fcc095 100644 --- a/packages/dockview-core/src/dockview/dockviewPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewPanelModel.ts @@ -4,10 +4,10 @@ import { IContentRenderer, ITabRenderer, } from './types'; -import { DockviewGroupPanel } from './dockviewGroupPanel'; import { IDisposable } from '../lifecycle'; import { IDockviewComponent } from './dockviewComponent'; import { PanelUpdateEvent } from '../panel/types'; +import { TabLocation } from './framework'; export interface IDockviewPanelModel extends IDisposable { readonly contentComponent: string; @@ -17,13 +17,16 @@ export interface IDockviewPanelModel extends IDisposable { update(event: PanelUpdateEvent): void; layout(width: number, height: number): void; init(params: GroupPanelPartInitParameters): void; - updateParentGroup(group: DockviewGroupPanel, isPanelVisible: boolean): void; + createTabRenderer(tabLocation: TabLocation): ITabRenderer; } export class DockviewPanelModel implements IDockviewPanelModel { private readonly _content: IContentRenderer; private readonly _tab: ITabRenderer; + private _params: GroupPanelPartInitParameters | undefined; + private _updateEvent: PanelUpdateEvent | undefined; + get content(): IContentRenderer { return this._content; } @@ -42,16 +45,23 @@ export class DockviewPanelModel implements IDockviewPanelModel { this._tab = this.createTabComponent(this.id, tabComponent); } - init(params: GroupPanelPartInitParameters): void { - this.content.init(params); - this.tab.init(params); + createTabRenderer(tabLocation: TabLocation): ITabRenderer { + const cmp = this.createTabComponent(this.id, this.tabComponent); + if (this._params) { + cmp.init({ ...this._params, tabLocation }); + } + if (this._updateEvent) { + cmp.update?.(this._updateEvent); + } + + return cmp; } - updateParentGroup( - _group: DockviewGroupPanel, - _isPanelVisible: boolean - ): void { - // noop + init(params: GroupPanelPartInitParameters): void { + this._params = params; + + this.content.init(params); + this.tab.init({ ...params, tabLocation: 'header' }); } layout(width: number, height: number): void { @@ -59,6 +69,8 @@ export class DockviewPanelModel implements IDockviewPanelModel { } update(event: PanelUpdateEvent): void { + this._updateEvent = event; + this.content.update?.(event); this.tab.update?.(event); } diff --git a/packages/dockview-core/src/dockview/framework.ts b/packages/dockview-core/src/dockview/framework.ts index bd6ccaff5..1ed239b46 100644 --- a/packages/dockview-core/src/dockview/framework.ts +++ b/packages/dockview-core/src/dockview/framework.ts @@ -11,9 +11,11 @@ export interface IGroupPanelBaseProps containerApi: DockviewApi; } +export type TabLocation = 'header' | 'headerOverflow'; + export type IDockviewPanelHeaderProps< T extends { [index: string]: any } = any -> = IGroupPanelBaseProps; +> = IGroupPanelBaseProps & { tabLocation: TabLocation }; export type IDockviewPanelProps = IGroupPanelBaseProps; diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 4b096c941..bf0861006 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -72,6 +72,7 @@ export interface DockviewOptions { */ noPanelsOverlay?: 'emptyGroup' | 'watermark'; theme?: DockviewTheme; + disableTabsOverflowList?: boolean; } export interface DockviewDndOverlayEvent extends IAcceptableEvent { @@ -119,6 +120,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => { dndEdges: undefined, theme: undefined, gap: undefined, + disableTabsOverflowList: undefined, }; return Object.keys(properties) as (keyof DockviewOptions)[]; diff --git a/packages/dockview-core/src/dockview/types.ts b/packages/dockview-core/src/dockview/types.ts index 3b2c8f112..5706f0721 100644 --- a/packages/dockview-core/src/dockview/types.ts +++ b/packages/dockview-core/src/dockview/types.ts @@ -4,6 +4,7 @@ import { DockviewApi } from '../api/component.api'; import { Optional } from '../types'; import { IDockviewGroupPanel } from './dockviewGroupPanel'; import { DockviewPanelRenderer } from '../overlay/overlayRenderContainer'; +import { TabLocation } from './framework'; export interface HeaderPartInitParameters { title: string; @@ -34,10 +35,14 @@ export interface IWatermarkRenderer init: (params: WatermarkRendererInitParameters) => void; } +export interface TabPartInitParameters extends GroupPanelPartInitParameters { + tabLocation: TabLocation; +} + export interface ITabRenderer extends Optional, RendererMethodOptionalList> { readonly element: HTMLElement; - init(parameters: GroupPanelPartInitParameters): void; + init(parameters: TabPartInitParameters): void; } export interface IContentRenderer diff --git a/packages/dockview-core/src/dom.ts b/packages/dockview-core/src/dom.ts index d61b1cacb..3dc9000a9 100644 --- a/packages/dockview-core/src/dom.ts +++ b/packages/dockview-core/src/dom.ts @@ -357,3 +357,25 @@ export class Classnames { } } } + +export function isChildEntirelyVisibleWithinParent( + child: HTMLElement, + parent: HTMLElement +): boolean { + // + const childPosition = getDomNodePagePosition(child); + const parentPosition = getDomNodePagePosition(parent); + + if (childPosition.left < parentPosition.left) { + return false; + } + + if ( + childPosition.left + childPosition.width > + parentPosition.left + parentPosition.width + ) { + return false; + } + + return true; +} diff --git a/packages/dockview-core/src/gridview/baseComponentGridview.ts b/packages/dockview-core/src/gridview/baseComponentGridview.ts index 93779f5a0..2ef754396 100644 --- a/packages/dockview-core/src/gridview/baseComponentGridview.ts +++ b/packages/dockview-core/src/gridview/baseComponentGridview.ts @@ -11,6 +11,9 @@ import { Classnames } from '../dom'; const nextLayoutId = sequentialNumberGenerator(); +/** + * A direction in which a panel can be moved or placed relative to another panel. + */ export type Direction = 'left' | 'right' | 'above' | 'below' | 'within'; export function toTarget(direction: Direction): Position { diff --git a/packages/dockview-core/src/scrollbar.scss b/packages/dockview-core/src/scrollbar.scss new file mode 100644 index 000000000..1276b51d8 --- /dev/null +++ b/packages/dockview-core/src/scrollbar.scss @@ -0,0 +1,28 @@ +.dv-scrollable { + position: relative; + overflow: hidden; + + .dv-scrollbar-horizontal { + position: absolute; + bottom: 0px; + left: 0px; + height: 4px; + border-radius: 2px; + background-color: transparent; + transition-property: background-color; + transition-timing-function: ease-in-out; + transition-duration: 1s; + transition-delay: 0s; + } + + &:hover, + &.dv-scrollable-resizing, + &.dv-scrollable-scrolling { + .dv-scrollbar-horizontal { + background-color: var( + --dv-scrollbar-background-color, + rgba(255, 255, 255, 0.25) + ); + } + } +} diff --git a/packages/dockview-core/src/scrollbar.ts b/packages/dockview-core/src/scrollbar.ts new file mode 100644 index 000000000..75db4e54a --- /dev/null +++ b/packages/dockview-core/src/scrollbar.ts @@ -0,0 +1,131 @@ +import { toggleClass, watchElementResize } from './dom'; +import { addDisposableListener } from './events'; +import { CompositeDisposable } from './lifecycle'; +import { clamp } from './math'; + +export class Scrollbar extends CompositeDisposable { + private readonly _element: HTMLElement; + private readonly _horizontalScrollbar: HTMLElement; + private _scrollLeft: number = 0; + private _animationTimer: any; + public static MouseWheelSpeed = 1; + + get element(): HTMLElement { + return this._element; + } + + constructor(private readonly scrollableElement: HTMLElement) { + super(); + + this._element = document.createElement('div'); + this._element.className = 'dv-scrollable'; + + this._horizontalScrollbar = document.createElement('div'); + this._horizontalScrollbar.className = 'dv-scrollbar-horizontal'; + + this.element.appendChild(scrollableElement); + this.element.appendChild(this._horizontalScrollbar); + + this.addDisposables( + addDisposableListener(this.element, 'wheel', (event) => { + this._scrollLeft += event.deltaY * Scrollbar.MouseWheelSpeed; + + this.calculateScrollbarStyles(); + }), + addDisposableListener( + this._horizontalScrollbar, + 'pointerdown', + (event) => { + event.preventDefault(); + + toggleClass(this.element, 'dv-scrollable-scrolling', true); + + const originalClientX = event.clientX; + const originalScrollLeft = this._scrollLeft; + + const onPointerMove = (event: PointerEvent) => { + const deltaX = event.clientX - originalClientX; + + const { clientWidth } = this.element; + const { scrollWidth } = this.scrollableElement; + const p = clientWidth / scrollWidth; + + this._scrollLeft = originalScrollLeft + deltaX / p; + this.calculateScrollbarStyles(); + }; + + const onEnd = () => { + toggleClass( + this.element, + 'dv-scrollable-scrolling', + false + ); + + document.removeEventListener( + 'pointermove', + onPointerMove + ); + document.removeEventListener('pointerup', onEnd); + document.removeEventListener('pointercancel', onEnd); + }; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onEnd); + document.addEventListener('pointercancel', onEnd); + } + ), + addDisposableListener(this.element, 'scroll', () => { + this.calculateScrollbarStyles(); + }), + addDisposableListener(this.scrollableElement, 'scroll', () => { + this._scrollLeft = this.scrollableElement.scrollLeft; + this.calculateScrollbarStyles(); + }), + watchElementResize(this.element, () => { + toggleClass(this.element, 'dv-scrollable-resizing', true); + + if (this._animationTimer) { + clearTimeout(this._animationTimer); + } + + this._animationTimer = setTimeout(() => { + clearTimeout(this._animationTimer); + toggleClass(this.element, 'dv-scrollable-resizing', false); + }, 500); + + this.calculateScrollbarStyles(); + }) + ); + } + + private calculateScrollbarStyles(): void { + const { clientWidth } = this.element; + const { scrollWidth } = this.scrollableElement; + + const hasScrollbar = scrollWidth > clientWidth; + + if (hasScrollbar) { + const px = clientWidth * (clientWidth / scrollWidth); + this._horizontalScrollbar.style.width = `${px}px`; + + this._scrollLeft = clamp( + this._scrollLeft, + 0, + this.scrollableElement.scrollWidth - clientWidth + ); + + this.scrollableElement.scrollLeft = this._scrollLeft; + + const percentageComplete = + this._scrollLeft / (scrollWidth - clientWidth); + + this._horizontalScrollbar.style.left = `${ + (clientWidth - px) * percentageComplete + }px`; + } else { + this._horizontalScrollbar.style.width = `0px`; + this._horizontalScrollbar.style.left = `0px`; + this._scrollLeft = 0; + } + } +} diff --git a/packages/dockview-core/src/theme/_space-mixin.scss b/packages/dockview-core/src/theme/_space-mixin.scss index b2e84cdbd..2482b6841 100644 --- a/packages/dockview-core/src/theme/_space-mixin.scss +++ b/packages/dockview-core/src/theme/_space-mixin.scss @@ -4,8 +4,7 @@ --dv-tab-margin: 0.5rem 0.25rem; --dv-tabs-and-actions-container-height: 44px; - - --dv-border-radius + --dv-border-radius: 20px; .dv-resize-container:has(> .dv-groupview) { border-radius: 8px; @@ -27,20 +26,26 @@ border: none; } + .dv-tabs-overflow-container, + .dv-tabs-overflow-dropdown-default { + border-radius: 8px; + height: unset !important; + } + + .dv-tab { + border-radius: 8px; + + .dv-svg { + height: 8px; + width: 8px; + } + } + .dv-groupview { border-radius: var(--dv-border-radius); .dv-tabs-and-actions-container { padding: 0px calc(var(--dv-border-radius) / 2); - - .dv-tab { - border-radius: 8px; - - .dv-svg { - height: 8px; - width: 8px; - } - } } .dv-content-container { diff --git a/packages/dockview-vue/src/utils.ts b/packages/dockview-vue/src/utils.ts index 45c23fafd..3b4c4fa71 100644 --- a/packages/dockview-vue/src/utils.ts +++ b/packages/dockview-vue/src/utils.ts @@ -2,7 +2,6 @@ import type { DockviewApi, DockviewGroupPanel, DockviewPanelApi, - GroupPanelPartInitParameters, IContentRenderer, IDockviewPanelHeaderProps, IGroupHeaderProps, @@ -12,6 +11,7 @@ import type { IWatermarkRenderer, PanelUpdateEvent, Parameters, + TabPartInitParameters, WatermarkRendererInitParameters, } from 'dockview-core'; import { @@ -121,7 +121,7 @@ export class VueRenderer private _api: DockviewPanelApi | undefined; private _containerApi: DockviewApi | undefined; - init(parameters: GroupPanelPartInitParameters): void { + init(parameters: TabPartInitParameters): void { this._api = parameters.api; this._containerApi = parameters.containerApi; @@ -129,6 +129,7 @@ export class VueRenderer params: parameters.params, api: parameters.api, containerApi: parameters.containerApi, + tabLocation: parameters.tabLocation, }; this._renderDisposable?.dispose(); diff --git a/packages/dockview/src/__tests__/dockview/defaultTab.spec.tsx b/packages/dockview/src/__tests__/dockview/defaultTab.spec.tsx index 93da0a000..85a8fe0ce 100644 --- a/packages/dockview/src/__tests__/dockview/defaultTab.spec.tsx +++ b/packages/dockview/src/__tests__/dockview/defaultTab.spec.tsx @@ -19,6 +19,7 @@ describe('defaultTab', () => { render( { render( { render( { render( { render( { render( { render( { const title = useTitle(api); @@ -96,7 +97,7 @@ export const DockviewDefaultTab: React.FunctionComponent< className="dv-default-tab" > {title} - {!hideClose && ( + {!hideClose && tabLocation !== 'headerOverflow' && (
; + private part?: ReactPart; get element(): HTMLElement { return this._element; @@ -17,7 +17,7 @@ export class ReactPanelHeaderPart implements ITabRenderer { constructor( public readonly id: string, - private readonly component: React.FunctionComponent, + private readonly component: React.FunctionComponent, private readonly reactPortalStore: ReactPortalStore ) { this._element = document.createElement('div'); @@ -30,7 +30,7 @@ export class ReactPanelHeaderPart implements ITabRenderer { //noop } - public init(parameters: GroupPanelPartInitParameters): void { + public init(parameters: TabPartInitParameters): void { this.part = new ReactPart( this.element, this.reactPortalStore, @@ -39,6 +39,7 @@ export class ReactPanelHeaderPart implements ITabRenderer { params: parameters.params, api: parameters.api, containerApi: parameters.containerApi, + tabLocation: parameters.tabLocation, } ); }