From 30f71e88c4d37b137c76a58c6d0b8daa9e8bde17 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sun, 27 Mar 2022 23:31:00 +0100 Subject: [PATCH 1/8] Implement scrolling of tabs in tabbar --- packages/widgets/src/dockpanel.ts | 29 +++ packages/widgets/src/tabbar.ts | 307 +++++++++++++++++++++++++++--- packages/widgets/style/tabbar.css | 128 ++++++++++++- 3 files changed, 440 insertions(+), 24 deletions(-) diff --git a/packages/widgets/src/dockpanel.ts b/packages/widgets/src/dockpanel.ts index ed045d96d..28a1444de 100644 --- a/packages/widgets/src/dockpanel.ts +++ b/packages/widgets/src/dockpanel.ts @@ -54,6 +54,9 @@ export class DockPanel extends Widget { if (options.addButtonEnabled !== undefined) { this._addButtonEnabled = options.addButtonEnabled; } + if (options.tabScrollingEnabled !== undefined) { + this._tabScrollingEnabled = options.tabScrollingEnabled; + } // Toggle the CSS mode attribute. this.dataset['mode'] = this._mode; @@ -255,6 +258,23 @@ export class DockPanel extends Widget { }); } + /** + * Whether scrolling of tabs in tab bars is enabled. + */ + get tabScrollingEnabled(): boolean { + return this._tabScrollingEnabled; + } + + /** + * Set whether the add buttons for each tab bar are enabled. + */ + set tabScrollingEnabled(value: boolean) { + this._tabScrollingEnabled = value; + each(this.tabBars(), tabbar => { + tabbar.scrollingEnabled = value; + }); + } + /** * Whether the dock panel is empty. */ @@ -914,6 +934,7 @@ export class DockPanel extends Widget { tabBar.tabsMovable = this._tabsMovable; tabBar.allowDeselect = false; tabBar.addButtonEnabled = this._addButtonEnabled; + tabBar.scrollingEnabled = this._tabScrollingEnabled; tabBar.removeBehavior = 'select-previous-tab'; tabBar.insertBehavior = 'select-tab-if-needed'; @@ -1054,6 +1075,7 @@ export class DockPanel extends Widget { private _tabsMovable: boolean = true; private _tabsConstrained: boolean = false; private _addButtonEnabled: boolean = false; + private _tabScrollingEnabled: boolean = false; private _pressData: Private.IPressData | null = null; private _layoutModified = new Signal(this); @@ -1136,6 +1158,13 @@ export namespace DockPanel { * The default is `'false'`. */ addButtonEnabled?: boolean; + + /** + * Enable scrolling in each of the dock panel's tab bars. + * + * The default is `'false'`. + */ + tabScrollingEnabled?: boolean; } /** diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 9f0e925a4..bb66dcbf4 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -350,6 +350,28 @@ export class TabBar extends Widget { } } + /** + * Whether scrolling is enabled. + */ + get scrollingEnabled(): boolean { + return this._scrollingEnabled; + } + + set scrollingEnabled(value: boolean) { + // Do nothing if the value does not change. + if (this._scrollingEnabled === value) { + return; + } + + this._scrollingEnabled = value; + if (value) { + this.node.classList.add('lm-mod-scrollable'); + } else { + this.node.classList.add('lm-mod-scrollable'); + } + this.maybeSwitchScrollButtons(); + } + /** * A read-only array of the titles in the tab bar. */ @@ -371,6 +393,20 @@ export class TabBar extends Widget { )[0] as HTMLUListElement; } + /** + * The tab bar content wrapper node. + * + * #### Notes + * This is the node which the content node and enables scrolling. + * + * Modifying this node directly can lead to undefined behavior. + */ + get contentWrapperNode(): HTMLUListElement { + return this.node.getElementsByClassName( + 'lm-TabBar-wrapper' + )[0] as HTMLUListElement; + } + /** * The tab bar add button node. * @@ -385,6 +421,18 @@ export class TabBar extends Widget { )[0] as HTMLDivElement; } + get scrollBeforeButtonNode(): HTMLDivElement { + return this.node.getElementsByClassName( + 'lm-TabBar-scrollBeforeButton' + )[0] as HTMLDivElement; + } + + get scrollAfterButtonNode(): HTMLDivElement { + return this.node.getElementsByClassName( + 'lm-TabBar-scrollAfterButton' + )[0] as HTMLDivElement; + } + /** * Add a tab to the end of the tab bar. * @@ -606,6 +654,9 @@ export class TabBar extends Widget { event.preventDefault(); event.stopPropagation(); break; + case 'scroll': + this._evtScroll(event); + break; } } @@ -615,6 +666,7 @@ export class TabBar extends Widget { protected onBeforeAttach(msg: Message): void { this.node.addEventListener('pointerdown', this); this.node.addEventListener('dblclick', this); + this.contentNode.addEventListener('scroll', this); } /** @@ -623,6 +675,7 @@ export class TabBar extends Widget { protected onAfterDetach(msg: Message): void { this.node.removeEventListener('pointerdown', this); this.node.removeEventListener('dblclick', this); + this.contentNode.removeEventListener('scroll', this); this._releaseMouse(); } @@ -641,6 +694,80 @@ export class TabBar extends Widget { content[i] = renderer.renderTab({ title, current, zIndex }); } VirtualDOM.render(content, this.contentNode); + this.maybeSwitchScrollButtons(); + } + + protected onResize(msg: Widget.ResizeMessage): void { + super.onResize(msg); + this.maybeSwitchScrollButtons(); + } + + protected maybeSwitchScrollButtons() { + const scrollBefore = this.scrollBeforeButtonNode; + const scrollAfter = this.scrollAfterButtonNode; + const state = this._scrollState; + + if (this.scrollingEnabled && state.totalSize > state.displayedSize) { + // show both buttons + scrollBefore.classList.remove('lm-mod-hidden'); + scrollAfter.classList.remove('lm-mod-hidden'); + } else { + // hide both buttons + scrollBefore.classList.add('lm-mod-hidden'); + scrollAfter.classList.add('lm-mod-hidden'); + } + this.updateScrollingHints(state); + } + + /** + * Adjust data reflecting the ability to scroll in each direction. + */ + protected updateScrollingHints(scrollState: Private.IScrollState) { + const wrapper = this.contentWrapperNode; + + if (!this.scrollingEnabled) { + delete wrapper.dataset['canScroll']; + return; + } + + const canScrollBefore = scrollState.position != 0; + const canScrollAfter = + scrollState.position != scrollState.totalSize - scrollState.displayedSize; + + if (canScrollBefore && canScrollAfter) { + wrapper.dataset['canScroll'] = 'both'; + } else if (canScrollBefore) { + wrapper.dataset['canScroll'] = 'before'; + } else if (canScrollAfter) { + wrapper.dataset['canScroll'] = 'after'; + } else { + delete wrapper.dataset['canScroll']; + } + } + + private get _scrollState(): Private.IScrollState { + const content = this.contentNode; + const contentRect = content.getBoundingClientRect(); + const isHorizontal = this.orientation === 'horizontal'; + const contentSize = isHorizontal ? contentRect.width : contentRect.height; + const scrollTotal = isHorizontal + ? content.scrollWidth + : content.scrollHeight; + const scroll = Math.round( + isHorizontal ? content.scrollLeft : content.scrollTop + ); + return { + displayedSize: Math.round(contentSize), + totalSize: scrollTotal, + position: scroll + }; + } + + /** + * Handle the `'dblclick'` event for the tab bar. + */ + private _evtScroll(event: Event): void { + this.updateScrollingHints(this._scrollState); } /** @@ -720,6 +847,52 @@ export class TabBar extends Widget { } } + protected beginScrolling(direction: '-' | '+') { + const initialRate = 5; + const rateIncrease = 1; + const maxRate = 20; + const intervalHandle = setInterval(() => { + if (!this._scrollData) { + this.stopScrolling(); + return; + } + const rate = this._scrollData.rate; + const direction = this._scrollData.scrollDirection; + const change = (direction == '+' ? 1 : -1) * rate; + if (this.orientation == 'horizontal') { + this.contentNode.scrollLeft += change; + } else { + this.contentNode.scrollTop += change; + } + this._scrollData.rate = Math.min( + this._scrollData.rate + rateIncrease, + maxRate + ); + const state = this._scrollState; + if ( + (direction == '-' && state.position == 0) || + (direction == '+' && + state.totalSize == state.position + state.displayedSize) + ) { + this.stopScrolling(); + } + }, 50); + this._scrollData = { + timerHandle: intervalHandle, + scrollDirection: direction, + rate: initialRate + }; + } + + protected stopScrolling() { + if (this._scrollData) { + clearInterval(this._scrollData.timerHandle); + } + this._scrollData = null; + const state = this._scrollState; + this.updateScrollingHints(state); + } + /** * Handle the `'pointerdown'` event for the tab bar. */ @@ -739,6 +912,19 @@ export class TabBar extends Widget { this.addButtonEnabled && this.addButtonNode.contains(event.target as HTMLElement); + let scrollBeforeButtonClicked = + this.scrollingEnabled && + this.scrollBeforeButtonNode.contains(event.target as HTMLElement); + + let scrollAfterButtonClicked = + this.scrollingEnabled && + this.scrollAfterButtonNode.contains(event.target as HTMLElement); + + const anyButtonClicked = + addButtonClicked || scrollAfterButtonClicked || scrollBeforeButtonClicked; + + const contentNode = this.contentNode; + // Lookup the tab nodes. let tabs = this.contentNode.children; @@ -747,8 +933,8 @@ export class TabBar extends Widget { return ElementExt.hitTest(tab, event.clientX, event.clientY); }); - // Do nothing if the press is not on a tab or the add button. - if (index === -1 && !addButtonClicked) { + // Do nothing if the press is not on a tab or any of the buttons. + if (index === -1 && !anyButtonClicked) { return; } @@ -762,6 +948,10 @@ export class TabBar extends Widget { index: index, pressX: event.clientX, pressY: event.clientY, + initialScrollPosition: + this.orientation == 'horizontal' + ? contentNode.scrollLeft + : contentNode.scrollTop, tabPos: -1, tabSize: -1, tabPressPos: -1, @@ -781,6 +971,10 @@ export class TabBar extends Widget { if (event.button === 1 || addButtonClicked) { return; } + if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { + this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); + return; + } // Do nothing else if the close icon is clicked. let icon = tabs[index].querySelector(this.renderer.closeIconSelector); @@ -882,8 +1076,24 @@ export class TabBar extends Widget { } } + let overBeforeScrollButton = + this.scrollingEnabled && + this.scrollBeforeButtonNode.contains(event.target as HTMLElement); + + let overAfterScrollButton = + this.scrollingEnabled && + this.scrollAfterButtonNode.contains(event.target as HTMLElement); + + if (overBeforeScrollButton || overAfterScrollButton) { + // Start scrolling if the mouse is over scroll buttons + this.beginScrolling(overBeforeScrollButton ? '-' : '+'); + } else { + // Stop scrolling if mouse is not over scroll buttons + this.stopScrolling(); + } + // Update the positions of the tabs. - Private.layoutTabs(tabs, data, event, this._orientation); + Private.layoutTabs(tabs, data, event, this._orientation, this._scrollState); } /** @@ -916,6 +1126,10 @@ export class TabBar extends Widget { // Clear the drag data. this._dragData = null; + if (this._scrollData) { + this.stopScrolling(); + return; + } // Handle clicking the add button. let addButtonClicked = this.addButtonEnabled && @@ -967,7 +1181,7 @@ export class TabBar extends Widget { } // Position the tab at its final resting position. - Private.finalizeTabPosition(data, this._orientation); + Private.finalizeTabPosition(data, this._orientation, this._scrollState); // Remove the dragging class from the tab so it can be transitioned. data.tab.classList.remove('lm-mod-dragging'); @@ -1207,7 +1421,9 @@ export class TabBar extends Widget { private _titlesEditable: boolean = false; private _previousTitle: Title | null = null; private _dragData: Private.IDragData | null = null; + private _scrollData: Private.IScrollData | null = null; private _addButtonEnabled: boolean = false; + private _scrollingEnabled: boolean = false; private _tabMoved = new Signal>(this); private _currentChanged = new Signal>( this @@ -1709,6 +1925,33 @@ namespace Private { */ export const DETACH_THRESHOLD = 20; + /** + * A struct which holds the scroll data for a tab bar. + */ + export interface IScrollData { + timerHandle: number; + scrollDirection: '+' | '-'; + rate: number; + } + + /** + * A struct which holds the scroll state for a tab bar. + */ + export interface IScrollState { + /** + * The size of the container where scrolling occurs (the visible part of the total). + */ + displayedSize: number; + /** + * The total size of the content to be scrolled. + */ + totalSize: number; + /** + * The current position (offset) of the scroll state. + */ + position: number; + } + /** * A struct which holds the drag data for a tab bar. */ @@ -1728,6 +1971,11 @@ namespace Private { */ pressX: number; + /** + * The initial scroll position + */ + initialScrollPosition: number; + /** * The mouse press client Y position. */ @@ -1823,13 +2071,32 @@ namespace Private { */ export function createNode(): HTMLDivElement { let node = document.createElement('div'); + let scrollBefore = document.createElement('div'); + scrollBefore.className = + 'lm-TabBar-button lm-TabBar-scrollButton lm-TabBar-scrollBeforeButton lm-mod-hidden'; + scrollBefore.setAttribute('role', 'button'); + scrollBefore.innerText = '<'; + let scrollAfter = document.createElement('div'); + scrollAfter.className = + 'lm-TabBar-button lm-TabBar-scrollButton lm-TabBar-scrollAfterButton lm-mod-hidden'; + scrollAfter.setAttribute('role', 'button'); + scrollAfter.innerText = '>'; + node.appendChild(scrollBefore); + let content = document.createElement('ul'); content.setAttribute('role', 'tablist'); content.className = 'lm-TabBar-content'; - node.appendChild(content); + + let wrapper = document.createElement('div'); + wrapper.className = 'lm-TabBar-wrapper'; + wrapper.appendChild(content); + node.appendChild(wrapper); + + node.appendChild(scrollAfter); let add = document.createElement('div'); - add.className = 'lm-TabBar-addButton lm-mod-hidden'; + add.setAttribute('role', 'button'); + add.className = 'lm-TabBar-button lm-TabBar-addButton lm-mod-hidden'; node.appendChild(add); return node; } @@ -1906,23 +2173,24 @@ namespace Private { tabs: HTMLCollection, data: IDragData, event: MouseEvent, - orientation: TabBar.Orientation + orientation: TabBar.Orientation, + scrollState: IScrollState ): void { // Compute the orientation-sensitive values. let pressPos: number; let localPos: number; let clientPos: number; - let clientSize: number; + const clientSize = scrollState.totalSize; + const scrollShift = scrollState.position - data.initialScrollPosition; + if (orientation === 'horizontal') { - pressPos = data.pressX; - localPos = event.clientX - data.contentRect!.left; + pressPos = data.pressX - scrollShift; + localPos = event.clientX - data.contentRect!.left + scrollState.position; clientPos = event.clientX; - clientSize = data.contentRect!.width; } else { - pressPos = data.pressY; - localPos = event.clientY - data.contentRect!.top; + pressPos = data.pressY - scrollShift; + localPos = event.clientY - data.contentRect!.top + scrollState.position; clientPos = event.clientY; - clientSize = data.contentRect!.height; } // Compute the target data. @@ -1964,15 +2232,10 @@ namespace Private { */ export function finalizeTabPosition( data: IDragData, - orientation: TabBar.Orientation + orientation: TabBar.Orientation, + scrollState: IScrollState ): void { - // Compute the orientation-sensitive client size. - let clientSize: number; - if (orientation === 'horizontal') { - clientSize = data.contentRect!.width; - } else { - clientSize = data.contentRect!.height; - } + const clientSize = scrollState.totalSize; // Compute the ideal final tab position. let ideal: number; diff --git a/packages/widgets/style/tabbar.css b/packages/widgets/style/tabbar.css index fabd830e7..9b0ce9cd9 100644 --- a/packages/widgets/style/tabbar.css +++ b/packages/widgets/style/tabbar.css @@ -33,11 +33,11 @@ list-style-type: none; } -.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-content { +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper > .lm-TabBar-content { flex-direction: row; } -.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-content { +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper > .lm-TabBar-content { flex-direction: column; } @@ -98,3 +98,127 @@ box-sizing: border-box; background: inherit; } + +.lm-TabBar-wrapper { + /* Remove wrapper from DOM when scrolling is not enabled */ + display: contents; + --lm-tabbar-scrollshadow-end: rgba(0, 0, 0, 0.3); + position: relative; +} + +.lm-TabBar.lm-mod-scrollable .lm-TabBar-wrapper { + /* Keep wrapper in DOM when scrolling is enabled */ + display: block; +} + +.lm-TabBar-content { + scrollbar-width: none; +} + +.lm-TabBar-content::-webkit-scrollbar { + display: none; +} + +.lm-TabBar[data-orientation='horizontal'].lm-mod-scrollable + > .lm-TabBar-wrapper { + overflow-x: hidden; + overflow-y: hidden; +} + +.lm-TabBar[data-orientation='vertical'].lm-mod-scrollable > .lm-TabBar-wrapper { + overflow-x: hidden; + overflow-y: auto; +} + +.lm-TabBar > .lm-TabBar-wrapper > .lm-TabBar-content { + width: 100%; +} + +.lm-TabBar[data-orientation='horizontal'].lm-mod-scrollable + > .lm-TabBar-wrapper + > .lm-TabBar-content { + overflow-x: scroll; + overflow-y: hidden; +} + +.lm-TabBar[data-orientation='vertical'].lm-mod-scrollable + > .lm-TabBar-wrapper + > .lm-TabBar-content { + overflow-x: hidden; + overflow-y: scroll; +} + +.lm-TabBar-wrapper::before, +.lm-TabBar-wrapper::after { + content: ''; + z-index: 10000; + display: none; + position: absolute; +} + +.lm-TabBar-wrapper[data-can-scroll='before']::before { + display: block; +} + +.lm-TabBar-wrapper[data-can-scroll='after']::after { + display: block; +} + +.lm-TabBar-wrapper[data-can-scroll='both']::before, +.lm-TabBar-wrapper[data-can-scroll='both']::after { + display: block; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::before, +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::after { + width: 5px; + height: 100%; + top: 0; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::before { + background: linear-gradient( + to left, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + left: 0; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::after { + background: linear-gradient( + to right, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + right: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::before, +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::after { + height: 5px; + width: 100%; + left: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::before { + background: linear-gradient( + to top, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + top: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::after { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + bottom: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-scrollButton { + transform: rotate(90deg); +} From de12bf88f1ba20c98e6e75b18e16b09b69d39e12 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 28 Mar 2022 17:50:50 +0100 Subject: [PATCH 2/8] Implement scrolling to active tab, fix styles, add release guard --- packages/widgets/src/tabbar.ts | 34 ++++++++++++++++++++++++++++++- packages/widgets/style/tabbar.css | 2 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index bb66dcbf4..86050efa9 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -367,7 +367,7 @@ export class TabBar extends Widget { if (value) { this.node.classList.add('lm-mod-scrollable'); } else { - this.node.classList.add('lm-mod-scrollable'); + this.node.classList.remove('lm-mod-scrollable'); } this.maybeSwitchScrollButtons(); } @@ -694,7 +694,11 @@ export class TabBar extends Widget { content[i] = renderer.renderTab({ title, current, zIndex }); } VirtualDOM.render(content, this.contentNode); + this.maybeSwitchScrollButtons(); + + // Scroll the current tab into view. + this.scrollCurrentIntoView(); } protected onResize(msg: Widget.ResizeMessage): void { @@ -702,6 +706,29 @@ export class TabBar extends Widget { this.maybeSwitchScrollButtons(); } + /** + * Scroll the current tab into view. + */ + protected scrollCurrentIntoView() { + if (this.scrollingEnabled) { + const contentNode = this.contentNode; + const currentNode = contentNode.children.item(this.currentIndex); + if (currentNode) { + currentNode.scrollIntoView(); + if (this.orientation == 'horizontal') { + contentNode.scrollTop = 0; + } else { + contentNode.scrollLeft = 0; + } + } else { + console.error('Current tab node not found'); + } + } + } + + /** + * Show/hide scroll buttons if needed. + */ protected maybeSwitchScrollButtons() { const scrollBefore = this.scrollBeforeButtonNode; const scrollAfter = this.scrollAfterButtonNode; @@ -1152,6 +1179,11 @@ export class TabBar extends Widget { return; } + // Do nothing if neither press nor release was on a tab. + if (index === -1 && data.index === -1) { + return; + } + // Ignore the release if the title is not closable. let title = this._titles[index]; if (!title.closable) { diff --git a/packages/widgets/style/tabbar.css b/packages/widgets/style/tabbar.css index 9b0ce9cd9..d9b79e41a 100644 --- a/packages/widgets/style/tabbar.css +++ b/packages/widgets/style/tabbar.css @@ -70,7 +70,7 @@ display: none !important; } -.lm-TabBar-addButton.lm-mod-hidden { +.lm-TabBar-button.lm-mod-hidden { display: none !important; } From 2db072434b80475d28a470af2ac0a21c3ac045b0 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 28 Mar 2022 23:10:17 +0100 Subject: [PATCH 3/8] Improve scrolling: smoother, better draging, wheel support - smoother: use `requestAnimationFrame` instead of `setInterval` - dragging: - detach by scrolling (requires scroll delta computation) - update drag when scrolling (by dispatching last event again to invoke handler as if mouse moved) - wheel: allow to scroll using mouse wheel --- packages/widgets/src/tabbar.ts | 161 ++++++++++++++++++++++++--------- 1 file changed, 117 insertions(+), 44 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 86050efa9..2335b762a 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -636,15 +636,19 @@ export class TabBar extends Widget { handleEvent(event: Event): void { switch (event.type) { case 'pointerdown': - this._evtPointerDown(event as PointerEvent); + this._lastMouseEvent = event as MouseEvent; + this._evtPointerDown(event as MouseEvent); break; case 'pointermove': - this._evtPointerMove(event as PointerEvent); + this._lastMouseEvent = event as MouseEvent; + this._evtPointerMove(event as MouseEvent); break; case 'pointerup': - this._evtPointerUp(event as PointerEvent); + this._lastMouseEvent = event as MouseEvent; + this._evtPointerUp(event as MouseEvent); break; case 'dblclick': + this._lastMouseEvent = event as MouseEvent; this._evtDblClick(event as MouseEvent); break; case 'keydown': @@ -657,6 +661,10 @@ export class TabBar extends Widget { case 'scroll': this._evtScroll(event); break; + case 'wheel': + this._evtWheel(event as WheelEvent); + event.preventDefault(); + break; } } @@ -667,6 +675,7 @@ export class TabBar extends Widget { this.node.addEventListener('pointerdown', this); this.node.addEventListener('dblclick', this); this.contentNode.addEventListener('scroll', this); + this.contentNode.addEventListener('wheel', this); } /** @@ -676,6 +685,7 @@ export class TabBar extends Widget { this.node.removeEventListener('pointerdown', this); this.node.removeEventListener('dblclick', this); this.contentNode.removeEventListener('scroll', this); + this.contentNode.removeEventListener('wheel', this); this._releaseMouse(); } @@ -797,6 +807,10 @@ export class TabBar extends Widget { this.updateScrollingHints(this._scrollState); } + private _evtWheel(event: WheelEvent): void { + this.scrollBy(event.deltaY); + } + /** * Handle the `'dblclick'` event for the tab bar. */ @@ -874,25 +888,47 @@ export class TabBar extends Widget { } } + protected scrollBy(change: number) { + const orientation = this.orientation; + const contentNode = this.contentNode; + + if (orientation == 'horizontal') { + contentNode.scrollLeft += change; + } else { + contentNode.scrollTop += change; + } + // Force-update drag state by dispatching last recorded mouse event. + if (this._lastMouseEvent) { + this._evtPointerMove(this._lastMouseEvent); + } + } + protected beginScrolling(direction: '-' | '+') { - const initialRate = 5; - const rateIncrease = 1; - const maxRate = 20; - const intervalHandle = setInterval(() => { + // How many pixels should be scrolled per second initially? + const initialRate = 150; + // By how much should the scrolling rate increase per second? + const rateIncrease = 80; + // What should be the maximal scrolling speed (pixels/second?) + const maxRate = 450; + + let previousTime = performance.now(); + + const step = () => { if (!this._scrollData) { this.stopScrolling(); return; } + const stepTime = performance.now(); + const secondsChange = (stepTime - previousTime) / 1000; + previousTime = stepTime; const rate = this._scrollData.rate; - const direction = this._scrollData.scrollDirection; - const change = (direction == '+' ? 1 : -1) * rate; - if (this.orientation == 'horizontal') { - this.contentNode.scrollLeft += change; - } else { - this.contentNode.scrollTop += change; - } + const direction = this._scrollData.direction; + + const change = (direction == '+' ? 1 : -1) * rate * secondsChange; + + this.scrollBy(change); this._scrollData.rate = Math.min( - this._scrollData.rate + rateIncrease, + this._scrollData.rate + rateIncrease * secondsChange, maxRate ); const state = this._scrollState; @@ -902,18 +938,26 @@ export class TabBar extends Widget { state.totalSize == state.position + state.displayedSize) ) { this.stopScrolling(); + return; } - }, 50); + window.requestAnimationFrame(step); + }; + + const shouldRequest = !this._scrollData; + this._scrollData = { - timerHandle: intervalHandle, - scrollDirection: direction, + direction: direction, rate: initialRate }; + + if (shouldRequest) { + window.requestAnimationFrame(step); + } } protected stopScrolling() { - if (this._scrollData) { - clearInterval(this._scrollData.timerHandle); + if (!this._scrollData) { + return; } this._scrollData = null; const state = this._scrollState; @@ -969,6 +1013,18 @@ export class TabBar extends Widget { event.preventDefault(); event.stopPropagation(); + // Add the document mouse up listener. + this.document.addEventListener('pointerup', this, true); + + // Do nothing else if the middle button or add button is clicked. + if (event.button === 1 || addButtonClicked) { + return; + } + if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { + this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); + return; + } + // Initialize the non-measured parts of the drag data. this._dragData = { tab: tabs[index] as HTMLElement, @@ -1039,6 +1095,21 @@ export class TabBar extends Widget { * Handle the `'pointermove'` event for the tab bar. */ private _evtPointerMove(event: PointerEvent | MouseEvent): void { + let overBeforeScrollButton = + this.scrollingEnabled && + this.scrollBeforeButtonNode.contains(event.target as HTMLElement); + + let overAfterScrollButton = + this.scrollingEnabled && + this.scrollAfterButtonNode.contains(event.target as HTMLElement); + + const isOverScrollButton = overBeforeScrollButton || overAfterScrollButton; + + if (!isOverScrollButton) { + // Stop scrolling if mouse is not over scroll buttons + this.stopScrolling(); + } + // Do nothing if no drag is in progress. let data = this._dragData; if (!data) { @@ -1049,14 +1120,22 @@ export class TabBar extends Widget { event.preventDefault(); event.stopPropagation(); - // Lookup the tab nodes. - let tabs = this.contentNode.children; + if (isOverScrollButton) { + // Start scrolling if the mouse is over scroll buttons + this.beginScrolling(overBeforeScrollButton ? '-' : '+'); + } // Bail early if the drag threshold has not been met. - if (!data.dragActive && !Private.dragExceeded(data, event)) { + if ( + !data.dragActive && + !Private.dragExceeded(data, event, this._scrollState) + ) { return; } + // Lookup the tab nodes. + let tabs = this.contentNode.children; + // Activate the drag if necessary. if (!data.dragActive) { // Fill in the rest of the drag data measurements. @@ -1103,22 +1182,6 @@ export class TabBar extends Widget { } } - let overBeforeScrollButton = - this.scrollingEnabled && - this.scrollBeforeButtonNode.contains(event.target as HTMLElement); - - let overAfterScrollButton = - this.scrollingEnabled && - this.scrollAfterButtonNode.contains(event.target as HTMLElement); - - if (overBeforeScrollButton || overAfterScrollButton) { - // Start scrolling if the mouse is over scroll buttons - this.beginScrolling(overBeforeScrollButton ? '-' : '+'); - } else { - // Stop scrolling if mouse is not over scroll buttons - this.stopScrolling(); - } - // Update the positions of the tabs. Private.layoutTabs(tabs, data, event, this._orientation, this._scrollState); } @@ -1135,6 +1198,9 @@ export class TabBar extends Widget { // Do nothing if no drag is in progress. const data = this._dragData; if (!data) { + if (this._scrollData) { + this.stopScrolling(); + } return; } @@ -1450,6 +1516,7 @@ export class TabBar extends Widget { private _titles: Title[] = []; private _orientation: TabBar.Orientation; private _document: Document | ShadowRoot; + private _lastMouseEvent: MouseEvent | null = null; private _titlesEditable: boolean = false; private _previousTitle: Title | null = null; private _dragData: Private.IDragData | null = null; @@ -1961,8 +2028,7 @@ namespace Private { * A struct which holds the scroll data for a tab bar. */ export interface IScrollData { - timerHandle: number; - scrollDirection: '+' | '-'; + direction: '+' | '-'; rate: number; } @@ -2177,12 +2243,19 @@ namespace Private { } /** - * Test if the event exceeds the drag threshold. + * Test if the event or scroll state exceeds the drag threshold. */ - export function dragExceeded(data: IDragData, event: MouseEvent): boolean { + export function dragExceeded( + data: IDragData, + event: MouseEvent, + scrollState: IScrollState | null + ): boolean { let dx = Math.abs(event.clientX - data.pressX); let dy = Math.abs(event.clientY - data.pressY); - return dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD; + let ds = scrollState + ? Math.abs(data.initialScrollPosition - scrollState.position) + : 0; + return dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD || ds >= DRAG_THRESHOLD; } /** From c6507cb75841a3ceefe9ddeb4412e92b14e0c59b Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 28 Mar 2022 23:27:35 +0100 Subject: [PATCH 4/8] Add docstrings --- packages/widgets/src/tabbar.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 2335b762a..742d9a064 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -801,12 +801,15 @@ export class TabBar extends Widget { } /** - * Handle the `'dblclick'` event for the tab bar. + * Handle the `'scroll'` event for the tab bar. */ private _evtScroll(event: Event): void { this.updateScrollingHints(this._scrollState); } + /** + * Handle the `'wheel'` event for the tab bar. + */ private _evtWheel(event: WheelEvent): void { this.scrollBy(event.deltaY); } @@ -888,6 +891,9 @@ export class TabBar extends Widget { } } + /** + * Scroll by a fixed number of pixels (sign defines direction). + */ protected scrollBy(change: number) { const orientation = this.orientation; const contentNode = this.contentNode; @@ -903,6 +909,9 @@ export class TabBar extends Widget { } } + /** + * Begin scrolling in given direction. + */ protected beginScrolling(direction: '-' | '+') { // How many pixels should be scrolled per second initially? const initialRate = 150; @@ -955,6 +964,9 @@ export class TabBar extends Widget { } } + /** + * Stop scrolling which was started with `beginScrolling` method. + */ protected stopScrolling() { if (!this._scrollData) { return; From 82ea6f67cecbda7f9ff874e02be9f4dceaa08a0b Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Tue, 29 Mar 2022 00:07:44 +0100 Subject: [PATCH 5/8] Decouple drag target index from click target index Reducing the complexity of the logic and fixing tab add/close actions --- packages/widgets/src/tabbar.ts | 42 ++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 742d9a064..c1296d332 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -1028,16 +1028,25 @@ export class TabBar extends Widget { // Add the document mouse up listener. this.document.addEventListener('pointerup', this, true); + if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { + this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); + return; + } + + this._clickedTabIndex = index; + // Do nothing else if the middle button or add button is clicked. if (event.button === 1 || addButtonClicked) { return; } - if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { - this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); + + // Do nothing else if the close icon is clicked. + let icon = tabs[index].querySelector(this.renderer.closeIconSelector); + if (icon && icon.contains(event.target as HTMLElement)) { return; } - // Initialize the non-measured parts of the drag data. + // Initialize the non-measured parts of the drag data, this._dragData = { tab: tabs[index] as HTMLElement, index: index, @@ -1207,14 +1216,7 @@ export class TabBar extends Widget { return; } - // Do nothing if no drag is in progress. const data = this._dragData; - if (!data) { - if (this._scrollData) { - this.stopScrolling(); - } - return; - } // Stop the event propagation. event.preventDefault(); @@ -1223,17 +1225,22 @@ export class TabBar extends Widget { // Remove the extra mouse event listeners. this.document.removeEventListener('pointermove', this, true); this.document.removeEventListener('pointerup', this, true); - this.document.removeEventListener('keydown', this, true); - this.document.removeEventListener('contextmenu', this, true); - // Handle a release when the drag is not active. - if (!data.dragActive) { + // Remove extra mouse event listeners which are only added when drag is in progress. + if (data) { + this.document.removeEventListener('pointermove', this, true); + this.document.removeEventListener('keydown', this, true); + this.document.removeEventListener('contextmenu', this, true); + } + + // Handle a release when the drag is not active or not in progress. + if (!data || !data.dragActive) { // Clear the drag data. this._dragData = null; + // Handle mouse release if scrolling was in progress. if (this._scrollData) { this.stopScrolling(); - return; } // Handle clicking the add button. let addButtonClicked = @@ -1253,12 +1260,12 @@ export class TabBar extends Widget { }); // Do nothing if the release is not on the original pressed tab. - if (index !== data.index) { + if (index !== this._clickedTabIndex) { return; } // Do nothing if neither press nor release was on a tab. - if (index === -1 && data.index === -1) { + if (index === -1 && this._clickedTabIndex === -1) { return; } @@ -1524,6 +1531,7 @@ export class TabBar extends Widget { } private _name: string; + private _clickedTabIndex: number = -1; private _currentIndex = -1; private _titles: Title[] = []; private _orientation: TabBar.Orientation; From 59bf5f1b4f911d5f2de1f3c7163f1433cbcdc84f Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Thu, 28 Jul 2022 18:49:40 +0100 Subject: [PATCH 6/8] Fix typos in docstrings --- packages/widgets/src/tabbar.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index c1296d332..1c1aedd49 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -352,6 +352,8 @@ export class TabBar extends Widget { /** * Whether scrolling is enabled. + * + * Note: for scrolling to work the tabs need to have `min-width` set. */ get scrollingEnabled(): boolean { return this._scrollingEnabled; @@ -397,7 +399,7 @@ export class TabBar extends Widget { * The tab bar content wrapper node. * * #### Notes - * This is the node which the content node and enables scrolling. + * This is the node which wraps the content node and enables scrolling. * * Modifying this node directly can lead to undefined behavior. */ @@ -1046,7 +1048,7 @@ export class TabBar extends Widget { return; } - // Initialize the non-measured parts of the drag data, + // Initialize the non-measured parts of the drag data. this._dragData = { tab: tabs[index] as HTMLElement, index: index, From 9345a612a8f12c7278bf33967edd3918d639df3a Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Thu, 28 Jul 2022 18:49:59 +0100 Subject: [PATCH 7/8] Add initial tests --- packages/widgets/tests/src/tabbar.spec.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/widgets/tests/src/tabbar.spec.ts b/packages/widgets/tests/src/tabbar.spec.ts index 082eb979c..3ec977012 100644 --- a/packages/widgets/tests/src/tabbar.spec.ts +++ b/packages/widgets/tests/src/tabbar.spec.ts @@ -144,6 +144,7 @@ describe('@lumino/widgets', () => { tabsMovable: true, allowDeselect: true, addButtonEnabled: true, + scrollingEnabled: true, insertBehavior: 'select-tab', removeBehavior: 'select-previous-tab', renderer @@ -152,6 +153,7 @@ describe('@lumino/widgets', () => { expect(newBar.tabsMovable).to.equal(true); expect(newBar.renderer).to.equal(renderer); expect(newBar.addButtonEnabled).to.equal(true); + expect(newBar.scrollingEnabled).to.equal(true); }); it('should add the `lm-TabBar` class', () => { @@ -652,6 +654,37 @@ describe('@lumino/widgets', () => { }); }); + describe('#scrollingEnabled', () => { + it('should get whether the scroll buttons are enabled', () => { + let bar = new TabBar(); + expect(bar.scrollingEnabled).to.equal(false); + }); + + it('should set whether the scroll buttons are enabled', () => { + let bar = new TabBar(); + bar.scrollingEnabled = true; + expect(bar.scrollingEnabled).to.equal(true); + }); + + it('should not show the scroll buttons if not set', () => { + populateBar(bar); + expect( + bar.scrollBeforeButtonNode.classList.contains('lm-mod-hidden') + ).to.equal(true); + expect( + bar.scrollAfterButtonNode.classList.contains('lm-mod-hidden') + ).to.equal(true); + + bar.scrollingEnabled = true; + expect( + bar.scrollBeforeButtonNode.classList.contains('lm-mod-hidden') + ).to.equal(false); + expect( + bar.scrollAfterButtonNode.classList.contains('lm-mod-hidden') + ).to.equal(true); + }); + }); + describe('#allowDeselect', () => { it('should determine whether a tab can be deselected by the user', () => { populateBar(bar); From c4e15130f0cda4a059576ee35fd054bb1c2562c8 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 3 Aug 2022 17:43:47 +0100 Subject: [PATCH 8/8] Fix rebase issues, lint, add missing option --- packages/widgets/src/tabbar.ts | 26 ++++++++------------------ packages/widgets/style/tabbar.css | 8 ++++++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 1c1aedd49..39d506ecb 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -54,6 +54,7 @@ export class TabBar extends Widget { this._document = options.document || document; this.tabsMovable = options.tabsMovable || false; this.titlesEditable = options.titlesEditable || false; + this._scrollingEnabled = options.scrollingEnabled || false; this.allowDeselect = options.allowDeselect || false; this.addButtonEnabled = options.addButtonEnabled || false; this.insertBehavior = options.insertBehavior || 'select-tab-if-needed'; @@ -1070,24 +1071,6 @@ export class TabBar extends Widget { detachRequested: false }; - // Add the document pointer up listener. - this.document.addEventListener('pointerup', this, true); - - // Do nothing else if the middle button or add button is clicked. - if (event.button === 1 || addButtonClicked) { - return; - } - if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { - this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); - return; - } - - // Do nothing else if the close icon is clicked. - let icon = tabs[index].querySelector(this.renderer.closeIconSelector); - if (icon && icon.contains(event.target as HTMLElement)) { - return; - } - // Add the extra listeners if the tabs are movable. if (this.tabsMovable) { this.document.addEventListener('pointermove', this, true); @@ -1683,6 +1666,13 @@ export namespace TabBar { */ addButtonEnabled?: boolean; + /** + * Whether scrolling is enabled. + * + * The default is `false`. + */ + scrollingEnabled?: boolean; + /** * The selection behavior when inserting a tab. * diff --git a/packages/widgets/style/tabbar.css b/packages/widgets/style/tabbar.css index d9b79e41a..5ad59d53b 100644 --- a/packages/widgets/style/tabbar.css +++ b/packages/widgets/style/tabbar.css @@ -33,11 +33,15 @@ list-style-type: none; } -.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper > .lm-TabBar-content { +.lm-TabBar[data-orientation='horizontal'] + > .lm-TabBar-wrapper + > .lm-TabBar-content { flex-direction: row; } -.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper > .lm-TabBar-content { +.lm-TabBar[data-orientation='vertical'] + > .lm-TabBar-wrapper + > .lm-TabBar-content { flex-direction: column; }