// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { IDisposable } from '@lumino/disposable'; import { ElementExt } from '@lumino/domutils'; import { Drag } from '@lumino/dragdrop'; import { Message } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from './widget'; /** * A widget which implements a canonical scroll bar. */ export class ScrollBar extends Widget { /** * Construct a new scroll bar. * * @param options - The options for initializing the scroll bar. */ constructor(options: ScrollBar.IOptions = {}) { super({ node: Private.createNode() }); this.addClass('lm-ScrollBar'); this.setFlag(Widget.Flag.DisallowLayout); // Set the orientation. this._orientation = options.orientation || 'vertical'; this.dataset['orientation'] = this._orientation; // Parse the rest of the options. if (options.maximum !== undefined) { this._maximum = Math.max(0, options.maximum); } if (options.page !== undefined) { this._page = Math.max(0, options.page); } if (options.value !== undefined) { this._value = Math.max(0, Math.min(options.value, this._maximum)); } } /** * A signal emitted when the user moves the scroll thumb. * * #### Notes * The payload is the current value of the scroll bar. */ get thumbMoved(): ISignal { return this._thumbMoved; } /** * A signal emitted when the user clicks a step button. * * #### Notes * The payload is whether a decrease or increase is requested. */ get stepRequested(): ISignal { return this._stepRequested; } /** * A signal emitted when the user clicks the scroll track. * * #### Notes * The payload is whether a decrease or increase is requested. */ get pageRequested(): ISignal { return this._pageRequested; } /** * Get the orientation of the scroll bar. */ get orientation(): ScrollBar.Orientation { return this._orientation; } /** * Set the orientation of the scroll bar. */ set orientation(value: ScrollBar.Orientation) { // Do nothing if the orientation does not change. if (this._orientation === value) { return; } // Release the mouse before making changes. this._releaseMouse(); // Update the internal orientation. this._orientation = value; this.dataset['orientation'] = value; // Schedule an update the scroll bar. this.update(); } /** * Get the current value of the scroll bar. */ get value(): number { return this._value; } /** * Set the current value of the scroll bar. * * #### Notes * The value will be clamped to the range `[0, maximum]`. */ set value(value: number) { // Clamp the value to the allowable range. value = Math.max(0, Math.min(value, this._maximum)); // Do nothing if the value does not change. if (this._value === value) { return; } // Update the internal value. this._value = value; // Schedule an update the scroll bar. this.update(); } /** * Get the page size of the scroll bar. * * #### Notes * The page size is the amount of visible content in the scrolled * region, expressed in data units. It determines the size of the * scroll bar thumb. */ get page(): number { return this._page; } /** * Set the page size of the scroll bar. * * #### Notes * The page size will be clamped to the range `[0, Infinity]`. */ set page(value: number) { // Clamp the page size to the allowable range. value = Math.max(0, value); // Do nothing if the value does not change. if (this._page === value) { return; } // Update the internal page size. this._page = value; // Schedule an update the scroll bar. this.update(); } /** * Get the maximum value of the scroll bar. */ get maximum(): number { return this._maximum; } /** * Set the maximum value of the scroll bar. * * #### Notes * The max size will be clamped to the range `[0, Infinity]`. */ set maximum(value: number) { // Clamp the value to the allowable range. value = Math.max(0, value); // Do nothing if the value does not change. if (this._maximum === value) { return; } // Update the internal values. this._maximum = value; // Clamp the current value to the new range. this._value = Math.min(this._value, value); // Schedule an update the scroll bar. this.update(); } /** * The scroll bar decrement button node. * * #### Notes * Modifying this node directly can lead to undefined behavior. */ get decrementNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-ScrollBar-button' )[0] as HTMLDivElement; } /** * The scroll bar increment button node. * * #### Notes * Modifying this node directly can lead to undefined behavior. */ get incrementNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-ScrollBar-button' )[1] as HTMLDivElement; } /** * The scroll bar track node. * * #### Notes * Modifying this node directly can lead to undefined behavior. */ get trackNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-ScrollBar-track' )[0] as HTMLDivElement; } /** * The scroll bar thumb node. * * #### Notes * Modifying this node directly can lead to undefined behavior. */ get thumbNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-ScrollBar-thumb' )[0] as HTMLDivElement; } /** * Handle the DOM events for the scroll bar. * * @param event - The DOM event sent to the scroll bar. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the scroll bar's DOM node. * * This should not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'mousedown': this._evtMouseDown(event as MouseEvent); break; case 'mousemove': this._evtMouseMove(event as MouseEvent); break; case 'mouseup': this._evtMouseUp(event as MouseEvent); break; case 'keydown': this._evtKeyDown(event as KeyboardEvent); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; } } /** * A method invoked on a 'before-attach' message. */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('mousedown', this); this.update(); } /** * A method invoked on an 'after-detach' message. */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('mousedown', this); this._releaseMouse(); } /** * A method invoked on an 'update-request' message. */ protected onUpdateRequest(msg: Message): void { // Convert the value and page into percentages. let value = (this._value * 100) / this._maximum; let page = (this._page * 100) / (this._page + this._maximum); // Clamp the value and page to the relevant range. value = Math.max(0, Math.min(value, 100)); page = Math.max(0, Math.min(page, 100)); // Fetch the thumb style. let thumbStyle = this.thumbNode.style; // Update the thumb style for the current orientation. if (this._orientation === 'horizontal') { thumbStyle.top = ''; thumbStyle.height = ''; thumbStyle.left = `${value}%`; thumbStyle.width = `${page}%`; thumbStyle.transform = `translate(${-value}%, 0%)`; } else { thumbStyle.left = ''; thumbStyle.width = ''; thumbStyle.top = `${value}%`; thumbStyle.height = `${page}%`; thumbStyle.transform = `translate(0%, ${-value}%)`; } } /** * Handle the `'keydown'` event for the scroll bar. */ private _evtKeyDown(event: KeyboardEvent): void { // Stop all input events during drag. event.preventDefault(); event.stopPropagation(); // Ignore anything except the `Escape` key. if (event.keyCode !== 27) { return; } // Fetch the previous scroll value. let value = this._pressData ? this._pressData.value : -1; // Release the mouse. this._releaseMouse(); // Restore the old scroll value if possible. if (value !== -1) { this._moveThumb(value); } } /** * Handle the `'mousedown'` event for the scroll bar. */ private _evtMouseDown(event: MouseEvent): void { // Do nothing if it's not a left mouse press. if (event.button !== 0) { return; } // Send an activate request to the scroll bar. This can be // used by message hooks to activate something relevant. this.activate(); // Do nothing if the mouse is already captured. if (this._pressData) { return; } // Find the pressed scroll bar part. let part = Private.findPart(this, event.target as HTMLElement); // Do nothing if the part is not of interest. if (!part) { return; } // Stop the event propagation. event.preventDefault(); event.stopPropagation(); // Override the mouse cursor. let override = Drag.overrideCursor('default'); // Set up the press data. this._pressData = { part, override, delta: -1, value: -1, mouseX: event.clientX, mouseY: event.clientY }; // Add the extra event listeners. document.addEventListener('mousemove', this, true); document.addEventListener('mouseup', this, true); document.addEventListener('keydown', this, true); document.addEventListener('contextmenu', this, true); // Handle a thumb press. if (part === 'thumb') { // Fetch the thumb node. let thumbNode = this.thumbNode; // Fetch the client rect for the thumb. let thumbRect = thumbNode.getBoundingClientRect(); // Update the press data delta for the current orientation. if (this._orientation === 'horizontal') { this._pressData.delta = event.clientX - thumbRect.left; } else { this._pressData.delta = event.clientY - thumbRect.top; } // Add the active class to the thumb node. thumbNode.classList.add('lm-mod-active'); // Store the current value in the press data. this._pressData.value = this._value; // Finished. return; } // Handle a track press. if (part === 'track') { // Fetch the client rect for the thumb. let thumbRect = this.thumbNode.getBoundingClientRect(); // Determine the direction for the page request. let dir: 'decrement' | 'increment'; if (this._orientation === 'horizontal') { dir = event.clientX < thumbRect.left ? 'decrement' : 'increment'; } else { dir = event.clientY < thumbRect.top ? 'decrement' : 'increment'; } // Start the repeat timer. this._repeatTimer = window.setTimeout(this._onRepeat, 350); // Emit the page requested signal. this._pageRequested.emit(dir); // Finished. return; } // Handle a decrement button press. if (part === 'decrement') { // Add the active class to the decrement node. this.decrementNode.classList.add('lm-mod-active'); // Start the repeat timer. this._repeatTimer = window.setTimeout(this._onRepeat, 350); // Emit the step requested signal. this._stepRequested.emit('decrement'); // Finished. return; } // Handle an increment button press. if (part === 'increment') { // Add the active class to the increment node. this.incrementNode.classList.add('lm-mod-active'); // Start the repeat timer. this._repeatTimer = window.setTimeout(this._onRepeat, 350); // Emit the step requested signal. this._stepRequested.emit('increment'); // Finished. return; } } /** * Handle the `'mousemove'` event for the scroll bar. */ private _evtMouseMove(event: MouseEvent): void { // Do nothing if no drag is in progress. if (!this._pressData) { return; } // Stop the event propagation. event.preventDefault(); event.stopPropagation(); // Update the mouse position. this._pressData.mouseX = event.clientX; this._pressData.mouseY = event.clientY; // Bail if the thumb is not being dragged. if (this._pressData.part !== 'thumb') { return; } // Get the client rect for the thumb and track. let thumbRect = this.thumbNode.getBoundingClientRect(); let trackRect = this.trackNode.getBoundingClientRect(); // Fetch the scroll geometry based on the orientation. let trackPos: number; let trackSpan: number; if (this._orientation === 'horizontal') { trackPos = event.clientX - trackRect.left - this._pressData.delta; trackSpan = trackRect.width - thumbRect.width; } else { trackPos = event.clientY - trackRect.top - this._pressData.delta; trackSpan = trackRect.height - thumbRect.height; } // Compute the desired value from the scroll geometry. let value = trackSpan === 0 ? 0 : (trackPos * this._maximum) / trackSpan; // Move the thumb to the computed value. this._moveThumb(value); } /** * Handle the `'mouseup'` event for the scroll bar. */ private _evtMouseUp(event: MouseEvent): void { // Do nothing if it's not a left mouse release. if (event.button !== 0) { return; } // Stop the event propagation. event.preventDefault(); event.stopPropagation(); // Release the mouse. this._releaseMouse(); } /** * Release the mouse and restore the node states. */ private _releaseMouse(): void { // Bail if there is no press data. if (!this._pressData) { return; } // Clear the repeat timer. clearTimeout(this._repeatTimer); this._repeatTimer = -1; // Clear the press data. this._pressData.override.dispose(); this._pressData = null; // Remove the extra event listeners. document.removeEventListener('mousemove', this, true); document.removeEventListener('mouseup', this, true); document.removeEventListener('keydown', this, true); document.removeEventListener('contextmenu', this, true); // Remove the active classes from the nodes. this.thumbNode.classList.remove('lm-mod-active'); this.decrementNode.classList.remove('lm-mod-active'); this.incrementNode.classList.remove('lm-mod-active'); } /** * Move the thumb to the specified position. */ private _moveThumb(value: number): void { // Clamp the value to the allowed range. value = Math.max(0, Math.min(value, this._maximum)); // Bail if the value does not change. if (this._value === value) { return; } // Update the internal value. this._value = value; // Schedule an update of the scroll bar. this.update(); // Emit the thumb moved signal. this._thumbMoved.emit(value); } /** * A timeout callback for repeating the mouse press. */ private _onRepeat = () => { // Clear the repeat timer id. this._repeatTimer = -1; // Bail if the mouse has been released. if (!this._pressData) { return; } // Look up the part that was pressed. let part = this._pressData.part; // Bail if the thumb was pressed. if (part === 'thumb') { return; } // Schedule the timer for another repeat. this._repeatTimer = window.setTimeout(this._onRepeat, 20); // Get the current mouse position. let mouseX = this._pressData.mouseX; let mouseY = this._pressData.mouseY; // Handle a decrement button repeat. if (part === 'decrement') { // Bail if the mouse is not over the button. if (!ElementExt.hitTest(this.decrementNode, mouseX, mouseY)) { return; } // Emit the step requested signal. this._stepRequested.emit('decrement'); // Finished. return; } // Handle an increment button repeat. if (part === 'increment') { // Bail if the mouse is not over the button. if (!ElementExt.hitTest(this.incrementNode, mouseX, mouseY)) { return; } // Emit the step requested signal. this._stepRequested.emit('increment'); // Finished. return; } // Handle a track repeat. if (part === 'track') { // Bail if the mouse is not over the track. if (!ElementExt.hitTest(this.trackNode, mouseX, mouseY)) { return; } // Fetch the thumb node. let thumbNode = this.thumbNode; // Bail if the mouse is over the thumb. if (ElementExt.hitTest(thumbNode, mouseX, mouseY)) { return; } // Fetch the client rect for the thumb. let thumbRect = thumbNode.getBoundingClientRect(); // Determine the direction for the page request. let dir: 'decrement' | 'increment'; if (this._orientation === 'horizontal') { dir = mouseX < thumbRect.left ? 'decrement' : 'increment'; } else { dir = mouseY < thumbRect.top ? 'decrement' : 'increment'; } // Emit the page requested signal. this._pageRequested.emit(dir); // Finished. return; } }; private _value = 0; private _page = 10; private _maximum = 100; private _repeatTimer = -1; private _orientation: ScrollBar.Orientation; private _pressData: Private.IPressData | null = null; private _thumbMoved = new Signal(this); private _stepRequested = new Signal(this); private _pageRequested = new Signal(this); } /** * The namespace for the `ScrollBar` class statics. */ export namespace ScrollBar { /** * A type alias for a scroll bar orientation. */ export type Orientation = 'horizontal' | 'vertical'; /** * An options object for creating a scroll bar. */ export interface IOptions { /** * The orientation of the scroll bar. * * The default is `'vertical'`. */ orientation?: Orientation; /** * The value for the scroll bar. * * The default is `0`. */ value?: number; /** * The page size for the scroll bar. * * The default is `10`. */ page?: number; /** * The maximum value for the scroll bar. * * The default is `100`. */ maximum?: number; } } /** * The namespace for the module implementation details. */ namespace Private { /** * A type alias for the parts of a scroll bar. */ export type ScrollBarPart = 'thumb' | 'track' | 'decrement' | 'increment'; /** * An object which holds mouse press data. */ export interface IPressData { /** * The scroll bar part which was pressed. */ part: ScrollBarPart; /** * The offset of the press in thumb coordinates, or -1. */ delta: number; /** * The scroll value at the time the thumb was pressed, or -1. */ value: number; /** * The disposable which will clear the override cursor. */ override: IDisposable; /** * The current X position of the mouse. */ mouseX: number; /** * The current Y position of the mouse. */ mouseY: number; } /** * Create the DOM node for a scroll bar. */ export function createNode(): HTMLElement { let node = document.createElement('div'); let decrement = document.createElement('div'); let increment = document.createElement('div'); let track = document.createElement('div'); let thumb = document.createElement('div'); decrement.className = 'lm-ScrollBar-button'; increment.className = 'lm-ScrollBar-button'; decrement.dataset['action'] = 'decrement'; increment.dataset['action'] = 'increment'; track.className = 'lm-ScrollBar-track'; thumb.className = 'lm-ScrollBar-thumb'; track.appendChild(thumb); node.appendChild(decrement); node.appendChild(track); node.appendChild(increment); return node; } /** * Find the scroll bar part which contains the given target. */ export function findPart( scrollBar: ScrollBar, target: HTMLElement ): ScrollBarPart | null { // Test the thumb. if (scrollBar.thumbNode.contains(target)) { return 'thumb'; } // Test the track. if (scrollBar.trackNode.contains(target)) { return 'track'; } // Test the decrement button. if (scrollBar.decrementNode.contains(target)) { return 'decrement'; } // Test the increment button. if (scrollBar.incrementNode.contains(target)) { return 'increment'; } // Indicate no match. return null; } }