From cfe37766a95160e534189579a6f91e65f830bac7 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Sat, 1 Mar 2025 21:04:06 +0000 Subject: [PATCH] feat: scrollbars --- .../__mocks__/mockDockviewPanelModel.ts | 2 +- .../dockview/dockviewComponent.spec.ts | 14 +- .../dockview/dockviewGroupPanelModel.spec.ts | 2 +- .../titlebar/tabOverflowControl.scss | 19 ++ .../components/titlebar/tabOverflowControl.ts | 25 ++ .../dockview/components/titlebar/tabs.scss | 73 ++---- .../src/dockview/components/titlebar/tabs.ts | 222 ++++++------------ .../components/titlebar/tabsContainer.scss | 4 + .../components/titlebar/tabsContainer.ts | 117 ++++++++- .../src/dockview/dockviewComponent.scss | 4 +- packages/dockview-core/src/scrollbar.scss | 28 +++ packages/dockview-core/src/scrollbar.ts | 131 +++++++++++ .../__tests__/dockview/defaultTab.spec.tsx | 7 + 13 files changed, 432 insertions(+), 216 deletions(-) create mode 100644 packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.scss create mode 100644 packages/dockview-core/src/dockview/components/titlebar/tabOverflowControl.ts create mode 100644 packages/dockview-core/src/scrollbar.scss create mode 100644 packages/dockview-core/src/scrollbar.ts diff --git a/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts b/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts index 35a301025..2a43a1796 100644 --- a/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts +++ b/packages/dockview-core/src/__tests__/__mocks__/mockDockviewPanelModel.ts @@ -18,7 +18,7 @@ export class DockviewPanelModelMock implements IDockviewPanelModel { // } - copyTabComponent(tabLocation: TabLocation): ITabRenderer { + createTabRenderer(tabLocation: TabLocation): ITabRenderer { return this.tab; } diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index ad6088d09..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-panel > .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-panel > .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-panel > .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/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index d87413609..719c26ca0 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -43,7 +43,7 @@ class TestModel implements IDockviewPanelModel { this.tab = new TestContentPart(id); } - copyTabComponent(tabLocation: TabLocation): ITabRenderer { + createTabRenderer(tabLocation: TabLocation): ITabRenderer { return new TestHeaderPart(this.id); } 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 index df15a09e5..5b9e7487f 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.scss @@ -1,6 +1,5 @@ -.dv-tabs-panel { +.dv-tabs-container { overflow: hidden; - &.dv-horizontal { .dv-tabs-container { .dv-tab { @@ -27,62 +26,38 @@ } } - .dv-tabs-container { - display: flex; - overflow: hidden; - scrollbar-width: thin; // firefox + display: flex; + height: 100%; + overflow: 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); - } + &::-webkit-scrollbar { + height: 3px; } - .dv-tabs-overflow-dropdown-default { - background-color: var( - --dv-activegroup-hiddenpanel-tab-background-color - ); - height: 100%; - color: var(--dv-activegroup-hiddenpanel-tab-color); - border-left: 1px solid var(--dv-tab-divider-color); + /* Track */ + &::-webkit-scrollbar-track { + background: transparent; + } - margin: var(--dv-tab-margin); - display: flex; - align-items: center; - flex-shrink: 0; + /* 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; - - > span { - padding-left: 0.25rem; - } - - > svg { - transform: rotate(90deg); - } + 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; diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index 659061b89..b8a6d5499 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -2,7 +2,6 @@ import { getPanelData } from '../../../dnd/dataTransfer'; import { isChildEntirelyVisibleWithinParent, OverflowObserver, - toggleClass, } from '../../../dom'; import { addDisposableListener, Emitter, Event } from '../../../events'; import { @@ -11,7 +10,7 @@ import { IValueDisposable, MutableDisposable, } from '../../../lifecycle'; -import { createChevronRightButton } from '../../../svg'; +import { Scrollbar } from '../../../scrollbar'; import { DockviewComponent } from '../../dockviewComponent'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel'; @@ -19,38 +18,14 @@ import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; import { Tab } from '../tab/tab'; import { TabDragEvent, TabDropIndexEvent } from './tabsContainer'; -type DropdownElement = { - element: HTMLElement; - update: (params: { tabs: number }) => void; - dispose?: () => void; -}; - -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}`; - }, - }; -} - export class Tabs extends CompositeDisposable { private readonly _element: HTMLElement; private readonly _tabsList: HTMLElement; + private readonly _observerDisposable = new MutableDisposable(); - private tabs: IValueDisposable[] = []; + private _tabs: IValueDisposable[] = []; private selectedIndex = -1; - - private readonly _dropdownDisposable = new MutableDisposable(); + private _showTabsOverflowControl = false; private readonly _onTabDragStart = new Emitter(); readonly onTabDragStart: Event = this._onTabDragStart.event; @@ -63,50 +38,79 @@ export class Tabs extends CompositeDisposable { readonly onWillShowOverlay: Event = this._onWillShowOverlay.event; - private dropdownPart: DropdownElement | null = null; - private _overflowTabs: string[] = []; + 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); + return this._tabs.map((_) => _.value.panel.id); } get size(): number { - return this.tabs.length; + return this._tabs.length; + } + + get tabs(): Tab[] { + return this._tabs.map((_) => _.value); } constructor( private readonly group: DockviewGroupPanel, - private readonly accessor: DockviewComponent + private readonly accessor: DockviewComponent, + options: { + showTabsOverflowControl: boolean; + } ) { super(); - this._element = document.createElement('div'); - this._element.className = 'dv-tabs-panel dv-horizontal'; - this._element.style.display = 'flex'; - this._element.style.overflow = 'auto'; this._tabsList = document.createElement('div'); - this._tabsList.className = 'dv-tabs-container'; - this._element.appendChild(this._tabsList); + this._tabsList.className = 'dv-tabs-container dv-horizontal'; - const observer = new OverflowObserver(this._tabsList); + this.showTabsOverflowControl = options.showTabsOverflowControl; + + const scrollbar = new Scrollbar(this._tabsList); + this._element = scrollbar.element; this.addDisposables( - this._dropdownDisposable, + this._onOverflowTabsChange, + this._observerDisposable, + scrollbar, this._onWillShowOverlay, this._onDrop, this._onTabDragStart, - observer, - observer.onDidChange((event) => { - const hasOverflow = event.hasScrollX || event.hasScrollY; - this.toggleDropdown({ reset: !hasOverflow }); - }), - addDisposableListener(this._tabsList, 'scroll', () => { - this.toggleDropdown({ reset: false }); - }), addDisposableListener(this.element, 'pointerdown', (event) => { if (event.defaultPrevented) { return; @@ -119,31 +123,31 @@ export class Tabs extends CompositeDisposable { } }), Disposable.from(() => { - for (const { value, disposable } of this.tabs) { + for (const { value, disposable } of this._tabs) { disposable.dispose(); value.dispose(); } - this.tabs = []; + this._tabs = []; }) ); } indexOf(id: string): number { - return this.tabs.findIndex((tab) => tab.value.panel.id === id); + return this._tabs.findIndex((tab) => tab.value.panel.id === id); } isActive(tab: Tab): boolean { return ( this.selectedIndex > -1 && - this.tabs[this.selectedIndex].value === tab + this._tabs[this.selectedIndex].value === tab ); } setActivePanel(panel: IDockviewPanel): void { let runningWidth = 0; - for (const tab of this.tabs) { + for (const tab of this._tabs) { const isActivePanel = panel.id === tab.value.panel.id; tab.value.setActive(isActivePanel); @@ -164,8 +168,8 @@ export class Tabs extends CompositeDisposable { } } - openPanel(panel: IDockviewPanel, index: number = this.tabs.length): void { - if (this.tabs.find((tab) => tab.value.panel.id === panel.id)) { + 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); @@ -219,7 +223,7 @@ export class Tabs extends CompositeDisposable { tab.onDrop((event) => { this._onDrop.fire({ event: event.nativeEvent, - index: this.tabs.findIndex((x) => x.value === tab), + index: this._tabs.findIndex((x) => x.value === tab), }); }), tab.onWillShowOverlay((event) => { @@ -242,7 +246,7 @@ export class Tabs extends CompositeDisposable { delete(id: string): void { const index = this.indexOf(id); - const tabToRemove = this.tabs.splice(index, 1)[0]; + const tabToRemove = this._tabs.splice(index, 1)[0]; const { value, disposable } = tabToRemove; @@ -253,9 +257,9 @@ export class Tabs extends CompositeDisposable { private addTab( tab: IValueDisposable, - index: number = this.tabs.length + index: number = this._tabs.length ): void { - if (index < 0 || index > this.tabs.length) { + if (index < 0 || index > this._tabs.length) { throw new Error('invalid location'); } @@ -264,10 +268,10 @@ export class Tabs extends CompositeDisposable { this._tabsList.children[index] ); - this.tabs = [ - ...this.tabs.slice(0, index), + this._tabs = [ + ...this._tabs.slice(0, index), tab, - ...this.tabs.slice(index), + ...this._tabs.slice(index), ]; if (this.selectedIndex < 0) { @@ -278,7 +282,7 @@ export class Tabs extends CompositeDisposable { private toggleDropdown(options: { reset: boolean }): void { const tabs = options.reset ? [] - : this.tabs + : this._tabs .filter( (tab) => !isChildEntirelyVisibleWithinParent( @@ -288,92 +292,6 @@ export class Tabs extends CompositeDisposable { ) .map((x) => x.value.panel.id); - 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.element.appendChild(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'; - - this.tabs - .filter((tab) => - this._overflowTabs.includes(tab.value.panel.id) - ) - .map((tab) => { - const panelObject = this.group.panels.find( - (panel) => panel === tab.value.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.value.element.scrollIntoView(); - tab.value.panel.api.setActive(); - }); - wrapper.appendChild(child); - - el.appendChild(wrapper); - }); - - this.accessor.popupService.openPopover(el, { - x: event.clientX, - y: event.clientY, - }); - }) - ); + 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 14815f8bc..1ed918a1c 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.scss @@ -25,4 +25,8 @@ flex-grow: 1; cursor: grab; } + + .dv-right-actions-container { + display: flex; + } } diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index e41952087..6a8b803e3 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -1,4 +1,9 @@ -import { IDisposable, CompositeDisposable } from '../../../lifecycle'; +import { + IDisposable, + CompositeDisposable, + Disposable, + MutableDisposable, +} from '../../../lifecycle'; import { addDisposableListener, Emitter, Event } from '../../../events'; import { Tab } from '../tab/tab'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; @@ -6,12 +11,13 @@ import { VoidContainer } from './voidContainer'; import { toggleClass } from '../../../dom'; 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; @@ -68,6 +74,10 @@ export class TabsContainer 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; @@ -129,7 +139,13 @@ export class TabsContainer this.preActionsContainer = document.createElement('div'); this.preActionsContainer.className = 'dv-pre-actions-container'; - this.tabs = new Tabs(group, accessor); + this.tabs = new Tabs(group, accessor, { + showTabsOverflowControl: false, + }); + + this.tabs.onOverflowTabsChange((event) => { + this.toggleDropdown(event); + }); this.voidContainer = new VoidContainer(this.accessor, this.group); @@ -287,4 +303,93 @@ export class TabsContainer 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'; + + this.tabs.tabs + .filter((tab) => this._overflowTabs.includes(tab.panel.id)) + .map((tab) => { + 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 b08c0ada0..3340e976a 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -19,7 +19,7 @@ .dv-groupview { &.dv-active-group { > .dv-tabs-and-actions-container - > .dv-tabs-panel + > .dv-scrollable > .dv-tabs-container > .dv-tab { &.dv-active-tab { @@ -38,7 +38,7 @@ } &.dv-inactive-group { > .dv-tabs-and-actions-container - > .dv-tabs-panel + > .dv-scrollable > .dv-tabs-container > .dv-tab { &.dv-active-tab { 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..535ebd8e2 --- /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 _element: HTMLElement; + private _horizontalScrollbar: HTMLElement; + private _scrollLeft: number = 0; + private _animationTimer: any; + 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/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(