UNPKG

22.3 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3/*-----------------------------------------------------------------------------
4| Copyright (c) 2014-2017, PhosphorJS Contributors
5|
6| Distributed under the terms of the BSD 3-Clause License.
7|
8| The full license is in the file LICENSE, distributed with this software.
9|----------------------------------------------------------------------------*/
10import { IDisposable } from '@lumino/disposable';
11
12import { ElementExt } from '@lumino/domutils';
13
14import { Drag } from '@lumino/dragdrop';
15
16import { Message } from '@lumino/messaging';
17
18import { ISignal, Signal } from '@lumino/signaling';
19
20import { Widget } from './widget';
21
22/**
23 * A widget which implements a canonical scroll bar.
24 */
25export class ScrollBar extends Widget {
26 /**
27 * Construct a new scroll bar.
28 *
29 * @param options - The options for initializing the scroll bar.
30 */
31 constructor(options: ScrollBar.IOptions = {}) {
32 super({ node: Private.createNode() });
33 this.addClass('lm-ScrollBar');
34 /* <DEPRECATED> */
35 this.addClass('p-ScrollBar');
36 /* </DEPRECATED> */
37 this.setFlag(Widget.Flag.DisallowLayout);
38
39 // Set the orientation.
40 this._orientation = options.orientation || 'vertical';
41 this.dataset['orientation'] = this._orientation;
42
43 // Parse the rest of the options.
44 if (options.maximum !== undefined) {
45 this._maximum = Math.max(0, options.maximum);
46 }
47 if (options.page !== undefined) {
48 this._page = Math.max(0, options.page);
49 }
50 if (options.value !== undefined) {
51 this._value = Math.max(0, Math.min(options.value, this._maximum));
52 }
53 }
54
55 /**
56 * A signal emitted when the user moves the scroll thumb.
57 *
58 * #### Notes
59 * The payload is the current value of the scroll bar.
60 */
61 get thumbMoved(): ISignal<this, number> {
62 return this._thumbMoved;
63 }
64
65 /**
66 * A signal emitted when the user clicks a step button.
67 *
68 * #### Notes
69 * The payload is whether a decrease or increase is requested.
70 */
71 get stepRequested(): ISignal<this, 'decrement' | 'increment'> {
72 return this._stepRequested;
73 }
74
75 /**
76 * A signal emitted when the user clicks the scroll track.
77 *
78 * #### Notes
79 * The payload is whether a decrease or increase is requested.
80 */
81 get pageRequested(): ISignal<this, 'decrement' | 'increment'> {
82 return this._pageRequested;
83 }
84
85 /**
86 * Get the orientation of the scroll bar.
87 */
88 get orientation(): ScrollBar.Orientation {
89 return this._orientation;
90 }
91
92 /**
93 * Set the orientation of the scroll bar.
94 */
95 set orientation(value: ScrollBar.Orientation) {
96 // Do nothing if the orientation does not change.
97 if (this._orientation === value) {
98 return;
99 }
100
101 // Release the mouse before making changes.
102 this._releaseMouse();
103
104 // Update the internal orientation.
105 this._orientation = value;
106 this.dataset['orientation'] = value;
107
108 // Schedule an update the scroll bar.
109 this.update();
110 }
111
112 /**
113 * Get the current value of the scroll bar.
114 */
115 get value(): number {
116 return this._value;
117 }
118
119 /**
120 * Set the current value of the scroll bar.
121 *
122 * #### Notes
123 * The value will be clamped to the range `[0, maximum]`.
124 */
125 set value(value: number) {
126 // Clamp the value to the allowable range.
127 value = Math.max(0, Math.min(value, this._maximum));
128
129 // Do nothing if the value does not change.
130 if (this._value === value) {
131 return;
132 }
133
134 // Update the internal value.
135 this._value = value;
136
137 // Schedule an update the scroll bar.
138 this.update();
139 }
140
141 /**
142 * Get the page size of the scroll bar.
143 *
144 * #### Notes
145 * The page size is the amount of visible content in the scrolled
146 * region, expressed in data units. It determines the size of the
147 * scroll bar thumb.
148 */
149 get page(): number {
150 return this._page;
151 }
152
153 /**
154 * Set the page size of the scroll bar.
155 *
156 * #### Notes
157 * The page size will be clamped to the range `[0, Infinity]`.
158 */
159 set page(value: number) {
160 // Clamp the page size to the allowable range.
161 value = Math.max(0, value);
162
163 // Do nothing if the value does not change.
164 if (this._page === value) {
165 return;
166 }
167
168 // Update the internal page size.
169 this._page = value;
170
171 // Schedule an update the scroll bar.
172 this.update();
173 }
174
175 /**
176 * Get the maximum value of the scroll bar.
177 */
178 get maximum(): number {
179 return this._maximum;
180 }
181
182 /**
183 * Set the maximum value of the scroll bar.
184 *
185 * #### Notes
186 * The max size will be clamped to the range `[0, Infinity]`.
187 */
188 set maximum(value: number) {
189 // Clamp the value to the allowable range.
190 value = Math.max(0, value);
191
192 // Do nothing if the value does not change.
193 if (this._maximum === value) {
194 return;
195 }
196
197 // Update the internal values.
198 this._maximum = value;
199
200 // Clamp the current value to the new range.
201 this._value = Math.min(this._value, value);
202
203 // Schedule an update the scroll bar.
204 this.update();
205 }
206
207 /**
208 * The scroll bar decrement button node.
209 *
210 * #### Notes
211 * Modifying this node directly can lead to undefined behavior.
212 */
213 get decrementNode(): HTMLDivElement {
214 return this.node.getElementsByClassName(
215 'lm-ScrollBar-button'
216 )[0] as HTMLDivElement;
217 }
218
219 /**
220 * The scroll bar increment button node.
221 *
222 * #### Notes
223 * Modifying this node directly can lead to undefined behavior.
224 */
225 get incrementNode(): HTMLDivElement {
226 return this.node.getElementsByClassName(
227 'lm-ScrollBar-button'
228 )[1] as HTMLDivElement;
229 }
230
231 /**
232 * The scroll bar track node.
233 *
234 * #### Notes
235 * Modifying this node directly can lead to undefined behavior.
236 */
237 get trackNode(): HTMLDivElement {
238 return this.node.getElementsByClassName(
239 'lm-ScrollBar-track'
240 )[0] as HTMLDivElement;
241 }
242
243 /**
244 * The scroll bar thumb node.
245 *
246 * #### Notes
247 * Modifying this node directly can lead to undefined behavior.
248 */
249 get thumbNode(): HTMLDivElement {
250 return this.node.getElementsByClassName(
251 'lm-ScrollBar-thumb'
252 )[0] as HTMLDivElement;
253 }
254
255 /**
256 * Handle the DOM events for the scroll bar.
257 *
258 * @param event - The DOM event sent to the scroll bar.
259 *
260 * #### Notes
261 * This method implements the DOM `EventListener` interface and is
262 * called in response to events on the scroll bar's DOM node.
263 *
264 * This should not be called directly by user code.
265 */
266 handleEvent(event: Event): void {
267 switch (event.type) {
268 case 'mousedown':
269 this._evtMouseDown(event as MouseEvent);
270 break;
271 case 'mousemove':
272 this._evtMouseMove(event as MouseEvent);
273 break;
274 case 'mouseup':
275 this._evtMouseUp(event as MouseEvent);
276 break;
277 case 'keydown':
278 this._evtKeyDown(event as KeyboardEvent);
279 break;
280 case 'contextmenu':
281 event.preventDefault();
282 event.stopPropagation();
283 break;
284 }
285 }
286
287 /**
288 * A method invoked on a 'before-attach' message.
289 */
290 protected onBeforeAttach(msg: Message): void {
291 this.node.addEventListener('mousedown', this);
292 this.update();
293 }
294
295 /**
296 * A method invoked on an 'after-detach' message.
297 */
298 protected onAfterDetach(msg: Message): void {
299 this.node.removeEventListener('mousedown', this);
300 this._releaseMouse();
301 }
302
303 /**
304 * A method invoked on an 'update-request' message.
305 */
306 protected onUpdateRequest(msg: Message): void {
307 // Convert the value and page into percentages.
308 let value = (this._value * 100) / this._maximum;
309 let page = (this._page * 100) / (this._page + this._maximum);
310
311 // Clamp the value and page to the relevant range.
312 value = Math.max(0, Math.min(value, 100));
313 page = Math.max(0, Math.min(page, 100));
314
315 // Fetch the thumb style.
316 let thumbStyle = this.thumbNode.style;
317
318 // Update the thumb style for the current orientation.
319 if (this._orientation === 'horizontal') {
320 thumbStyle.top = '';
321 thumbStyle.height = '';
322 thumbStyle.left = `${value}%`;
323 thumbStyle.width = `${page}%`;
324 thumbStyle.transform = `translate(${-value}%, 0%)`;
325 } else {
326 thumbStyle.left = '';
327 thumbStyle.width = '';
328 thumbStyle.top = `${value}%`;
329 thumbStyle.height = `${page}%`;
330 thumbStyle.transform = `translate(0%, ${-value}%)`;
331 }
332 }
333
334 /**
335 * Handle the `'keydown'` event for the scroll bar.
336 */
337 private _evtKeyDown(event: KeyboardEvent): void {
338 // Stop all input events during drag.
339 event.preventDefault();
340 event.stopPropagation();
341
342 // Ignore anything except the `Escape` key.
343 if (event.keyCode !== 27) {
344 return;
345 }
346
347 // Fetch the previous scroll value.
348 let value = this._pressData ? this._pressData.value : -1;
349
350 // Release the mouse.
351 this._releaseMouse();
352
353 // Restore the old scroll value if possible.
354 if (value !== -1) {
355 this._moveThumb(value);
356 }
357 }
358
359 /**
360 * Handle the `'mousedown'` event for the scroll bar.
361 */
362 private _evtMouseDown(event: MouseEvent): void {
363 // Do nothing if it's not a left mouse press.
364 if (event.button !== 0) {
365 return;
366 }
367
368 // Send an activate request to the scroll bar. This can be
369 // used by message hooks to activate something relevant.
370 this.activate();
371
372 // Do nothing if the mouse is already captured.
373 if (this._pressData) {
374 return;
375 }
376
377 // Find the pressed scroll bar part.
378 let part = Private.findPart(this, event.target as HTMLElement);
379
380 // Do nothing if the part is not of interest.
381 if (!part) {
382 return;
383 }
384
385 // Stop the event propagation.
386 event.preventDefault();
387 event.stopPropagation();
388
389 // Override the mouse cursor.
390 let override = Drag.overrideCursor('default');
391
392 // Set up the press data.
393 this._pressData = {
394 part,
395 override,
396 delta: -1,
397 value: -1,
398 mouseX: event.clientX,
399 mouseY: event.clientY
400 };
401
402 // Add the extra event listeners.
403 document.addEventListener('mousemove', this, true);
404 document.addEventListener('mouseup', this, true);
405 document.addEventListener('keydown', this, true);
406 document.addEventListener('contextmenu', this, true);
407
408 // Handle a thumb press.
409 if (part === 'thumb') {
410 // Fetch the thumb node.
411 let thumbNode = this.thumbNode;
412
413 // Fetch the client rect for the thumb.
414 let thumbRect = thumbNode.getBoundingClientRect();
415
416 // Update the press data delta for the current orientation.
417 if (this._orientation === 'horizontal') {
418 this._pressData.delta = event.clientX - thumbRect.left;
419 } else {
420 this._pressData.delta = event.clientY - thumbRect.top;
421 }
422
423 // Add the active class to the thumb node.
424 thumbNode.classList.add('lm-mod-active');
425 /* <DEPRECATED> */
426 thumbNode.classList.add('p-mod-active');
427 /* </DEPRECATED> */
428
429 // Store the current value in the press data.
430 this._pressData.value = this._value;
431
432 // Finished.
433 return;
434 }
435
436 // Handle a track press.
437 if (part === 'track') {
438 // Fetch the client rect for the thumb.
439 let thumbRect = this.thumbNode.getBoundingClientRect();
440
441 // Determine the direction for the page request.
442 let dir: 'decrement' | 'increment';
443 if (this._orientation === 'horizontal') {
444 dir = event.clientX < thumbRect.left ? 'decrement' : 'increment';
445 } else {
446 dir = event.clientY < thumbRect.top ? 'decrement' : 'increment';
447 }
448
449 // Start the repeat timer.
450 this._repeatTimer = window.setTimeout(this._onRepeat, 350);
451
452 // Emit the page requested signal.
453 this._pageRequested.emit(dir);
454
455 // Finished.
456 return;
457 }
458
459 // Handle a decrement button press.
460 if (part === 'decrement') {
461 // Add the active class to the decrement node.
462 this.decrementNode.classList.add('lm-mod-active');
463 /* <DEPRECATED> */
464 this.decrementNode.classList.add('p-mod-active');
465 /* </DEPRECATED> */
466
467 // Start the repeat timer.
468 this._repeatTimer = window.setTimeout(this._onRepeat, 350);
469
470 // Emit the step requested signal.
471 this._stepRequested.emit('decrement');
472
473 // Finished.
474 return;
475 }
476
477 // Handle an increment button press.
478 if (part === 'increment') {
479 // Add the active class to the increment node.
480 this.incrementNode.classList.add('lm-mod-active');
481 /* <DEPRECATED> */
482 this.incrementNode.classList.add('p-mod-active');
483 /* </DEPRECATED> */
484
485 // Start the repeat timer.
486 this._repeatTimer = window.setTimeout(this._onRepeat, 350);
487
488 // Emit the step requested signal.
489 this._stepRequested.emit('increment');
490
491 // Finished.
492 return;
493 }
494 }
495
496 /**
497 * Handle the `'mousemove'` event for the scroll bar.
498 */
499 private _evtMouseMove(event: MouseEvent): void {
500 // Do nothing if no drag is in progress.
501 if (!this._pressData) {
502 return;
503 }
504
505 // Stop the event propagation.
506 event.preventDefault();
507 event.stopPropagation();
508
509 // Update the mouse position.
510 this._pressData.mouseX = event.clientX;
511 this._pressData.mouseY = event.clientY;
512
513 // Bail if the thumb is not being dragged.
514 if (this._pressData.part !== 'thumb') {
515 return;
516 }
517
518 // Get the client rect for the thumb and track.
519 let thumbRect = this.thumbNode.getBoundingClientRect();
520 let trackRect = this.trackNode.getBoundingClientRect();
521
522 // Fetch the scroll geometry based on the orientation.
523 let trackPos: number;
524 let trackSpan: number;
525 if (this._orientation === 'horizontal') {
526 trackPos = event.clientX - trackRect.left - this._pressData.delta;
527 trackSpan = trackRect.width - thumbRect.width;
528 } else {
529 trackPos = event.clientY - trackRect.top - this._pressData.delta;
530 trackSpan = trackRect.height - thumbRect.height;
531 }
532
533 // Compute the desired value from the scroll geometry.
534 let value = trackSpan === 0 ? 0 : (trackPos * this._maximum) / trackSpan;
535
536 // Move the thumb to the computed value.
537 this._moveThumb(value);
538 }
539
540 /**
541 * Handle the `'mouseup'` event for the scroll bar.
542 */
543 private _evtMouseUp(event: MouseEvent): void {
544 // Do nothing if it's not a left mouse release.
545 if (event.button !== 0) {
546 return;
547 }
548
549 // Stop the event propagation.
550 event.preventDefault();
551 event.stopPropagation();
552
553 // Release the mouse.
554 this._releaseMouse();
555 }
556
557 /**
558 * Release the mouse and restore the node states.
559 */
560 private _releaseMouse(): void {
561 // Bail if there is no press data.
562 if (!this._pressData) {
563 return;
564 }
565
566 // Clear the repeat timer.
567 clearTimeout(this._repeatTimer);
568 this._repeatTimer = -1;
569
570 // Clear the press data.
571 this._pressData.override.dispose();
572 this._pressData = null;
573
574 // Remove the extra event listeners.
575 document.removeEventListener('mousemove', this, true);
576 document.removeEventListener('mouseup', this, true);
577 document.removeEventListener('keydown', this, true);
578 document.removeEventListener('contextmenu', this, true);
579
580 // Remove the active classes from the nodes.
581 this.thumbNode.classList.remove('lm-mod-active');
582 this.decrementNode.classList.remove('lm-mod-active');
583 this.incrementNode.classList.remove('lm-mod-active');
584 /* <DEPRECATED> */
585 this.thumbNode.classList.remove('p-mod-active');
586 this.decrementNode.classList.remove('p-mod-active');
587 this.incrementNode.classList.remove('p-mod-active');
588 /* </DEPRECATED> */
589 }
590
591 /**
592 * Move the thumb to the specified position.
593 */
594 private _moveThumb(value: number): void {
595 // Clamp the value to the allowed range.
596 value = Math.max(0, Math.min(value, this._maximum));
597
598 // Bail if the value does not change.
599 if (this._value === value) {
600 return;
601 }
602
603 // Update the internal value.
604 this._value = value;
605
606 // Schedule an update of the scroll bar.
607 this.update();
608
609 // Emit the thumb moved signal.
610 this._thumbMoved.emit(value);
611 }
612
613 /**
614 * A timeout callback for repeating the mouse press.
615 */
616 private _onRepeat = () => {
617 // Clear the repeat timer id.
618 this._repeatTimer = -1;
619
620 // Bail if the mouse has been released.
621 if (!this._pressData) {
622 return;
623 }
624
625 // Look up the part that was pressed.
626 let part = this._pressData.part;
627
628 // Bail if the thumb was pressed.
629 if (part === 'thumb') {
630 return;
631 }
632
633 // Schedule the timer for another repeat.
634 this._repeatTimer = window.setTimeout(this._onRepeat, 20);
635
636 // Get the current mouse position.
637 let mouseX = this._pressData.mouseX;
638 let mouseY = this._pressData.mouseY;
639
640 // Handle a decrement button repeat.
641 if (part === 'decrement') {
642 // Bail if the mouse is not over the button.
643 if (!ElementExt.hitTest(this.decrementNode, mouseX, mouseY)) {
644 return;
645 }
646
647 // Emit the step requested signal.
648 this._stepRequested.emit('decrement');
649
650 // Finished.
651 return;
652 }
653
654 // Handle an increment button repeat.
655 if (part === 'increment') {
656 // Bail if the mouse is not over the button.
657 if (!ElementExt.hitTest(this.incrementNode, mouseX, mouseY)) {
658 return;
659 }
660
661 // Emit the step requested signal.
662 this._stepRequested.emit('increment');
663
664 // Finished.
665 return;
666 }
667
668 // Handle a track repeat.
669 if (part === 'track') {
670 // Bail if the mouse is not over the track.
671 if (!ElementExt.hitTest(this.trackNode, mouseX, mouseY)) {
672 return;
673 }
674
675 // Fetch the thumb node.
676 let thumbNode = this.thumbNode;
677
678 // Bail if the mouse is over the thumb.
679 if (ElementExt.hitTest(thumbNode, mouseX, mouseY)) {
680 return;
681 }
682
683 // Fetch the client rect for the thumb.
684 let thumbRect = thumbNode.getBoundingClientRect();
685
686 // Determine the direction for the page request.
687 let dir: 'decrement' | 'increment';
688 if (this._orientation === 'horizontal') {
689 dir = mouseX < thumbRect.left ? 'decrement' : 'increment';
690 } else {
691 dir = mouseY < thumbRect.top ? 'decrement' : 'increment';
692 }
693
694 // Emit the page requested signal.
695 this._pageRequested.emit(dir);
696
697 // Finished.
698 return;
699 }
700 };
701
702 private _value = 0;
703 private _page = 10;
704 private _maximum = 100;
705 private _repeatTimer = -1;
706 private _orientation: ScrollBar.Orientation;
707 private _pressData: Private.IPressData | null = null;
708 private _thumbMoved = new Signal<this, number>(this);
709 private _stepRequested = new Signal<this, 'decrement' | 'increment'>(this);
710 private _pageRequested = new Signal<this, 'decrement' | 'increment'>(this);
711}
712
713/**
714 * The namespace for the `ScrollBar` class statics.
715 */
716export namespace ScrollBar {
717 /**
718 * A type alias for a scroll bar orientation.
719 */
720 export type Orientation = 'horizontal' | 'vertical';
721
722 /**
723 * An options object for creating a scroll bar.
724 */
725 export interface IOptions {
726 /**
727 * The orientation of the scroll bar.
728 *
729 * The default is `'vertical'`.
730 */
731 orientation?: Orientation;
732
733 /**
734 * The value for the scroll bar.
735 *
736 * The default is `0`.
737 */
738 value?: number;
739
740 /**
741 * The page size for the scroll bar.
742 *
743 * The default is `10`.
744 */
745 page?: number;
746
747 /**
748 * The maximum value for the scroll bar.
749 *
750 * The default is `100`.
751 */
752 maximum?: number;
753 }
754}
755
756/**
757 * The namespace for the module implementation details.
758 */
759namespace Private {
760 /**
761 * A type alias for the parts of a scroll bar.
762 */
763 export type ScrollBarPart = 'thumb' | 'track' | 'decrement' | 'increment';
764
765 /**
766 * An object which holds mouse press data.
767 */
768 export interface IPressData {
769 /**
770 * The scroll bar part which was pressed.
771 */
772 part: ScrollBarPart;
773
774 /**
775 * The offset of the press in thumb coordinates, or -1.
776 */
777 delta: number;
778
779 /**
780 * The scroll value at the time the thumb was pressed, or -1.
781 */
782 value: number;
783
784 /**
785 * The disposable which will clear the override cursor.
786 */
787 override: IDisposable;
788
789 /**
790 * The current X position of the mouse.
791 */
792 mouseX: number;
793
794 /**
795 * The current Y position of the mouse.
796 */
797 mouseY: number;
798 }
799
800 /**
801 * Create the DOM node for a scroll bar.
802 */
803 export function createNode(): HTMLElement {
804 let node = document.createElement('div');
805 let decrement = document.createElement('div');
806 let increment = document.createElement('div');
807 let track = document.createElement('div');
808 let thumb = document.createElement('div');
809 decrement.className = 'lm-ScrollBar-button';
810 increment.className = 'lm-ScrollBar-button';
811 decrement.dataset['action'] = 'decrement';
812 increment.dataset['action'] = 'increment';
813 track.className = 'lm-ScrollBar-track';
814 thumb.className = 'lm-ScrollBar-thumb';
815 /* <DEPRECATED> */
816 decrement.classList.add('p-ScrollBar-button');
817 increment.classList.add('p-ScrollBar-button');
818 track.classList.add('p-ScrollBar-track');
819 thumb.classList.add('p-ScrollBar-thumb');
820 /* </DEPRECATED> */
821 track.appendChild(thumb);
822 node.appendChild(decrement);
823 node.appendChild(track);
824 node.appendChild(increment);
825 return node;
826 }
827
828 /**
829 * Find the scroll bar part which contains the given target.
830 */
831 export function findPart(
832 scrollBar: ScrollBar,
833 target: HTMLElement
834 ): ScrollBarPart | null {
835 // Test the thumb.
836 if (scrollBar.thumbNode.contains(target)) {
837 return 'thumb';
838 }
839
840 // Test the track.
841 if (scrollBar.trackNode.contains(target)) {
842 return 'track';
843 }
844
845 // Test the decrement button.
846 if (scrollBar.decrementNode.contains(target)) {
847 return 'decrement';
848 }
849
850 // Test the increment button.
851 if (scrollBar.incrementNode.contains(target)) {
852 return 'increment';
853 }
854
855 // Indicate no match.
856 return null;
857 }
858}