From 4dab95125b15ba143665610b82550e0efb6bac07 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 7 Feb 2020 18:28:50 -0800 Subject: [PATCH] nouislider pr merge conflict resolution --- ipywidgets/widgets/widget_float.py | 6 +- packages/controls/css/nouislider.css | 413 ++++++++++++++++++++++ packages/controls/css/widgets-base.css | 121 +------ packages/controls/package.json | 3 +- packages/controls/src/widget_float.ts | 84 ++++- packages/controls/src/widget_int.ts | 265 +++++++------- packages/controls/src/widget_selection.ts | 154 +++++--- yarn.lock | 22 +- 8 files changed, 750 insertions(+), 318 deletions(-) create mode 100644 packages/controls/css/nouislider.css diff --git a/ipywidgets/widgets/widget_float.py b/ipywidgets/widgets/widget_float.py index 126a463855..a8fde26c3e 100644 --- a/ipywidgets/widgets/widget_float.py +++ b/ipywidgets/widgets/widget_float.py @@ -166,7 +166,7 @@ class FloatSlider(_BoundedFloat): """ _view_name = Unicode('FloatSliderView').tag(sync=True) _model_name = Unicode('FloatSliderModel').tag(sync=True) - step = CFloat(0.1, help="Minimum step to increment the value").tag(sync=True) + step = CFloat(0.1, allow_none=True, help="Minimum step to increment the value").tag(sync=True) orientation = CaselessStrEnum(values=['horizontal', 'vertical'], default_value='horizontal', help="Vertical or horizontal.").tag(sync=True) readout = Bool(True, help="Display the current value of the slider next to it.").tag(sync=True) @@ -207,7 +207,7 @@ class FloatLogSlider(_BoundedLogFloat): """ _view_name = Unicode('FloatLogSliderView').tag(sync=True) _model_name = Unicode('FloatLogSliderModel').tag(sync=True) - step = CFloat(0.1, help="Minimum step in the exponent to increment the value").tag(sync=True) + step = CFloat(0.1, allow_none=True, help="Minimum step in the exponent to increment the value").tag(sync=True) orientation = CaselessStrEnum(values=['horizontal', 'vertical'], default_value='horizontal', help="Vertical or horizontal.").tag(sync=True) readout = Bool(True, help="Display the current value of the slider next to it.").tag(sync=True) @@ -350,7 +350,7 @@ class FloatRangeSlider(_BoundedFloatRange): """ _view_name = Unicode('FloatRangeSliderView').tag(sync=True) _model_name = Unicode('FloatRangeSliderModel').tag(sync=True) - step = CFloat(0.1, help="Minimum step to increment the value").tag(sync=True) + step = CFloat(0.1, allow_none=True, help="Minimum step to increment the value").tag(sync=True) orientation = CaselessStrEnum(values=['horizontal', 'vertical'], default_value='horizontal', help="Vertical or horizontal.").tag(sync=True) readout = Bool(True, help="Display the current value of the slider next to it.").tag(sync=True) diff --git a/packages/controls/css/nouislider.css b/packages/controls/css/nouislider.css new file mode 100644 index 0000000000..a18eeabdc5 --- /dev/null +++ b/packages/controls/css/nouislider.css @@ -0,0 +1,413 @@ +/* taken from https://github.com/leongersen/noUiSlider/blob/5481ab7450a6d48ab05c32e256718fb04dd90245/distribute/nouislider.css + * each class has been prefixed with .widget-slider to scope it to our slider + */ + +/*! nouislider - 14.1.1 - 12/15/2019 */ +/* Functional styling; + * These styles are required for noUiSlider to function. + * You don't need to change these rules to apply your design. + +MIT License + +Copyright (c) 2019 Léon Gersen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + +.widget-slider .noUi-target, +.widget-slider .noUi-target * { + -webkit-touch-callout: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-user-select: none; + -ms-touch-action: none; + touch-action: none; + -ms-user-select: none; + -moz-user-select: none; + user-select: none; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.widget-slider .noUi-target { + position: relative; +} +.widget-slider .noUi-base, +.widget-slider .noUi-connects { + width: 100%; + height: 100%; + position: relative; + z-index: 1; +} +/* Wrapper for all connect elements. + */ +.widget-slider .noUi-connects { + overflow: hidden; + z-index: 0; +} +.widget-slider .noUi-connect, +.widget-slider .noUi-origin { + will-change: transform; + position: absolute; + z-index: 1; + top: 0; + right: 0; + -ms-transform-origin: 0 0; + -webkit-transform-origin: 0 0; + -webkit-transform-style: preserve-3d; + transform-origin: 0 0; + transform-style: flat; +} +.widget-slider .noUi-connect { + height: 100%; + width: 100%; +} +.widget-slider .noUi-origin { + height: 10%; + width: 10%; +} +/* Offset direction + */ +.widget-slider .noUi-txt-dir-rtl.noUi-horizontal .noUi-origin { + left: 0; + right: auto; +} +/* Give origins 0 height/width so they don't interfere with clicking the + * connect elements. + */ +.widget-slider .noUi-vertical .noUi-origin { + width: 0; +} +.widget-slider .noUi-horizontal .noUi-origin { + height: 0; +} +.widget-slider .noUi-handle { + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + position: absolute; +} +.widget-slider .noUi-touch-area { + height: 100%; + width: 100%; +} +.widget-slider .noUi-state-tap .noUi-connect, +.widget-slider .noUi-state-tap .noUi-origin { + -webkit-transition: transform 0.3s; + transition: transform 0.3s; +} +.widget-slider .noUi-state-drag * { + cursor: inherit !important; +} +/* Slider size and handle placement; + */ +.widget-slider .noUi-horizontal { + height: 18px; +} +.widget-slider .noUi-horizontal .noUi-handle { + width: 34px; + height: 28px; + right: -17px; + top: -6px; +} +.widget-slider .noUi-vertical { + height: 100%; + width: 18px; +} +.widget-slider .noUi-vertical .noUi-handle { + width: 28px; + height: 34px; + right: -6px; + top: -17px; +} +.widget-slider .noUi-txt-dir-rtl.noUi-horizontal .noUi-handle { + left: -17px; + right: auto; +} +/* Styling; + * Giving the connect element a border radius causes issues with using transform: scale + */ +.widget-slider .noUi-target { + background: #fafafa; + border-radius: 4px; + border: 1px solid #d3d3d3; + box-shadow: inset 0 1px 1px #f0f0f0, 0 3px 6px -5px #bbb; +} +.widget-slider .noUi-connects { + border-radius: 3px; +} +.widget-slider .noUi-connect { + background: #3fb8af; +} +/* Handles and cursors; + */ +.widget-slider .noUi-draggable { + cursor: ew-resize; +} +.widget-slider .noUi-vertical .noUi-draggable { + cursor: ns-resize; +} +.widget-slider .noUi-handle { + border: 1px solid #d9d9d9; + border-radius: 3px; + background: #fff; + cursor: default; + box-shadow: inset 0 0 1px #fff, inset 0 1px 7px #ebebeb, 0 3px 6px -3px #bbb; +} +.widget-slider .noUi-active { + box-shadow: inset 0 0 1px #fff, inset 0 1px 7px #ddd, 0 3px 6px -3px #bbb; +} +/* Handle stripes; + */ +.widget-slider .noUi-handle:before, +.widget-slider .noUi-handle:after { + content: ''; + display: block; + position: absolute; + height: 14px; + width: 1px; + background: #e8e7e6; + left: 14px; + top: 6px; +} +.widget-slider .noUi-handle:after { + left: 17px; +} +.widget-slider .noUi-vertical .noUi-handle:before, +.widget-slider .noUi-vertical .noUi-handle:after { + width: 14px; + height: 1px; + left: 6px; + top: 14px; +} +.widget-slider .noUi-vertical .noUi-handle:after { + top: 17px; +} +/* Disabled state; + */ +[disabled] .noUi-connect { + background: #b8b8b8; +} +[disabled].noUi-target, +[disabled].noUi-handle, +[disabled] .noUi-handle { + cursor: not-allowed; +} +/* Base; + * + */ +.widget-slider .noUi-pips, +.widget-slider .noUi-pips * { + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.widget-slider .noUi-pips { + position: absolute; + color: #999; +} +/* Values; + * + */ +.widget-slider .noUi-value { + position: absolute; + white-space: nowrap; + text-align: center; +} +.widget-slider .noUi-value-sub { + color: #ccc; + font-size: 10px; +} +/* Markings; + * + */ +.widget-slider .noUi-marker { + position: absolute; + background: #ccc; +} +.widget-slider .noUi-marker-sub { + background: #aaa; +} +.widget-slider .noUi-marker-large { + background: #aaa; +} +/* Horizontal layout; + * + */ +.widget-slider .noUi-pips-horizontal { + padding: 10px 0; + height: 80px; + top: 100%; + left: 0; + width: 100%; +} +.widget-slider .noUi-value-horizontal { + -webkit-transform: translate(-50%, 50%); + transform: translate(-50%, 50%); +} +.widget-slider .noUi-rtl .noUi-value-horizontal { + -webkit-transform: translate(50%, 50%); + transform: translate(50%, 50%); +} +.widget-slider .noUi-marker-horizontal.noUi-marker { + margin-left: -1px; + width: 2px; + height: 5px; +} +.widget-slider .noUi-marker-horizontal.noUi-marker-sub { + height: 10px; +} +.widget-slider .noUi-marker-horizontal.noUi-marker-large { + height: 15px; +} +/* Vertical layout; + * + */ +.widget-slider .noUi-pips-vertical { + padding: 0 10px; + height: 100%; + top: 0; + left: 100%; +} +.widget-slider .noUi-value-vertical { + -webkit-transform: translate(0, -50%); + transform: translate(0, -50%); + padding-left: 25px; +} +.widget-slider .noUi-rtl .noUi-value-vertical { + -webkit-transform: translate(0, 50%); + transform: translate(0, 50%); +} +.widget-slider .noUi-marker-vertical.noUi-marker { + width: 5px; + height: 2px; + margin-top: -1px; +} +.widget-slider .noUi-marker-vertical.noUi-marker-sub { + width: 10px; +} +.widget-slider .noUi-marker-vertical.noUi-marker-large { + width: 15px; +} +.widget-slider .noUi-tooltip { + display: block; + position: absolute; + border: 1px solid #d9d9d9; + border-radius: 3px; + background: #fff; + color: #000; + padding: 5px; + text-align: center; + white-space: nowrap; +} +.widget-slider .noUi-horizontal .noUi-tooltip { + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); + left: 50%; + bottom: 120%; +} +.widget-slider .noUi-vertical .noUi-tooltip { + -webkit-transform: translate(0, -50%); + transform: translate(0, -50%); + top: 50%; + right: 120%; +} + +/* Custom CSS */ + +.widget-slider .noUi-connect { + background: rgb(33, 150, 243); +} + +.widget-slider .noUi-horizontal { + height: var(--jp-widgets-slider-track-thickness); +} + +.widget-slider .noUi-vertical { + width: var(--jp-widgets-slider-track-thickness); +} + +.widget-slider .noUi-horizontal .noUi-handle { + width: var(--jp-widgets-slider-handle-size); + height: var(--jp-widgets-slider-handle-size); + margin-top: calc( + ( + var(--jp-widgets-slider-track-thickness) - + var(--jp-widgets-slider-handle-size) + ) / 2 - var(--jp-widgets-slider-border-width) + ); + margin-left: calc( + var(--jp-widgets-slider-handle-size) / -2 + + var(--jp-widgets-slider-border-width) + ); + border-radius: 50%; + top: 0; +} + +.widget-slider .noUi-vertical .noUi-handle { + height: var(--jp-widgets-slider-handle-size); + width: var(--jp-widgets-slider-handle-size); + margin-top: calc( + ( + var(--jp-widgets-slider-track-thickness) - + var(--jp-widgets-slider-handle-size) + ) / 2 - var(--jp-widgets-slider-border-width) + ); + margin-left: calc( + var(--jp-widgets-slider-handle-size) / -2 + + var(--jp-widgets-slider-border-width) + ); + border-radius: 50%; + top: 0; +} + +.widget-slider .noUi-handle:after { + content: none; +} + +.widget-slider .noUi-handle:before { + content: none; +} + +.widget-slider .noUi-target { + background: #fafafa; + border-radius: 4px; + border: 1px; + /* box-shadow: inset 0 1px 1px #F0F0F0, 0 3px 6px -5px #BBB; */ +} + +.widget-slider .ui-slider { + border: var(--jp-widgets-slider-border-width) solid var(--jp-layout-color3); + background: var(--jp-layout-color3); + box-sizing: border-box; + position: relative; + border-radius: 0px; +} + +.widget-slider .noUi-horizontal .noUi-handle { + right: -13px; +} + +.widget-slider .noUi-handle { + width: var(--jp-widgets-slider-handle-size); + border: 1px solid #d9d9d9; + border-radius: 3px; + background: #fff; + cursor: default; + box-shadow: none; + outline: none; +} + +.widget-slider .noUi-handle:hover, +.noUi-handle:focus { + background-color: var(--jp-widgets-slider-active-handle-color); + border: var(--jp-widgets-slider-border-width) solid + var(--jp-widgets-slider-active-handle-color); +} + +.widget-slider .noUi-connects { + overflow: hidden; + z-index: 0; + background: var(--jp-layout-color3); +} diff --git a/packages/controls/css/widgets-base.css b/packages/controls/css/widgets-base.css index c36ca6ff1e..540a2cdb1f 100644 --- a/packages/controls/css/widgets-base.css +++ b/packages/controls/css/widgets-base.css @@ -9,6 +9,7 @@ */ @import './lumino.css'; +@import './nouislider.css'; :root { --jp-widgets-color: var(--jp-content-font-color1); @@ -477,109 +478,7 @@ border-color: var(--jp-widgets-input-focus-border-color); } -/* Widget Slider */ - -.widget-slider .ui-slider { - /* Slider Track */ - border: var(--jp-widgets-slider-border-width) solid var(--jp-layout-color3); - background: var(--jp-layout-color3); - box-sizing: border-box; - position: relative; - border-radius: 0px; -} - -.widget-slider .ui-slider .ui-slider-handle { - /* Slider Handle */ - outline: none !important; /* focused slider handles are colored - see below */ - position: absolute; - background-color: var(--jp-widgets-slider-handle-background-color); - border: var(--jp-widgets-slider-border-width) solid - var(--jp-widgets-slider-handle-border-color); - box-sizing: border-box; - z-index: 1; - background-image: none; /* Override jquery-ui */ -} - -/* Override jquery-ui */ -.widget-slider .ui-slider .ui-slider-handle:hover, -.widget-slider .ui-slider .ui-slider-handle:focus { - background-color: var(--jp-widgets-slider-active-handle-color); - border: var(--jp-widgets-slider-border-width) solid - var(--jp-widgets-slider-active-handle-color); -} - -.widget-slider .ui-slider .ui-slider-handle:active { - background-color: var(--jp-widgets-slider-active-handle-color); - border-color: var(--jp-widgets-slider-active-handle-color); - z-index: 2; - transform: scale(1.2); -} - -.widget-slider .ui-slider .ui-slider-range { - /* Interval between the two specified value of a double slider */ - position: absolute; - background: var(--jp-widgets-slider-active-handle-color); - z-index: 0; -} - -/* Shapes of Slider Handles */ - -.widget-hslider .ui-slider .ui-slider-handle { - width: var(--jp-widgets-slider-handle-size); - height: var(--jp-widgets-slider-handle-size); - margin-top: calc( - ( - var(--jp-widgets-slider-track-thickness) - - var(--jp-widgets-slider-handle-size) - ) / 2 - var(--jp-widgets-slider-border-width) - ); - margin-left: calc( - var(--jp-widgets-slider-handle-size) / -2 + - var(--jp-widgets-slider-border-width) - ); - border-radius: 50%; - top: 0; -} - -.widget-vslider .ui-slider .ui-slider-handle { - width: var(--jp-widgets-slider-handle-size); - height: var(--jp-widgets-slider-handle-size); - margin-bottom: calc( - var(--jp-widgets-slider-handle-size) / -2 + - var(--jp-widgets-slider-border-width) - ); - margin-left: calc( - ( - var(--jp-widgets-slider-track-thickness) - - var(--jp-widgets-slider-handle-size) - ) / 2 - var(--jp-widgets-slider-border-width) - ); - border-radius: 50%; - left: 0; -} - -.widget-hslider .ui-slider .ui-slider-range { - height: calc(var(--jp-widgets-slider-track-thickness) * 2); - margin-top: calc( - ( - var(--jp-widgets-slider-track-thickness) - - var(--jp-widgets-slider-track-thickness) * 2 - ) / 2 - var(--jp-widgets-slider-border-width) - ); -} - -.widget-vslider .ui-slider .ui-slider-range { - width: calc(var(--jp-widgets-slider-track-thickness) * 2); - margin-left: calc( - ( - var(--jp-widgets-slider-track-thickness) - - var(--jp-widgets-slider-track-thickness) * 2 - ) / 2 - var(--jp-widgets-slider-border-width) - ); -} - /* Horizontal Slider */ - .widget-hslider { width: var(--jp-widgets-inline-width); height: var(--jp-widgets-inline-height); @@ -608,16 +507,6 @@ flex: 1 1 var(--jp-widgets-inline-width-short); } -.widget-hslider .ui-slider { - /* Inner, invisible slide div */ - height: var(--jp-widgets-slider-track-thickness); - margin-top: calc( - (var(--jp-widgets-inline-height) - var(--jp-widgets-slider-track-thickness)) / - 2 - ); - width: 100%; -} - /* Vertical Slider */ .widget-vbox .widget-label { @@ -647,14 +536,6 @@ flex-direction: column; } -.widget-vslider .ui-slider-vertical { - /* Inner, invisible slide div */ - width: var(--jp-widgets-slider-track-thickness); - flex-grow: 1; - margin-left: auto; - margin-right: auto; -} - /* Widget Progress Styling */ .progress-bar { diff --git a/packages/controls/package.json b/packages/controls/package.json index 448b1a3f69..45c6972728 100644 --- a/packages/controls/package.json +++ b/packages/controls/package.json @@ -42,7 +42,7 @@ "@lumino/widgets": "^1.3.0", "d3-format": "^1.3.0", "jquery": "^3.1.1", - "jquery-ui": "^1.12.1" + "nouislider": "^14.1.1" }, "devDependencies": { "@jupyterlab/services": "^5.0.0-beta.2", @@ -51,6 +51,7 @@ "@types/mathjax": "^0.0.35", "@types/mocha": "^5.2.7", "@types/node": "^12.0.10", + "@types/nouislider": "^9.0.6", "chai": "^4.0.0", "css-loader": "^3.4.0", "expect.js": "^0.3.1", diff --git a/packages/controls/src/widget_float.ts b/packages/controls/src/widget_float.ts index bdd9189f89..218e840e16 100644 --- a/packages/controls/src/widget_float.ts +++ b/packages/controls/src/widget_float.ts @@ -12,6 +12,8 @@ import { import { format } from 'd3-format'; +import noUiSlider from 'nouislider'; + export class FloatModel extends CoreDescriptionModel { defaults(): Backbone.ObjectHash { return { @@ -118,11 +120,20 @@ export class FloatSliderView extends IntSliderView { export class FloatLogSliderView extends BaseIntSliderView { update(options?: any): void { super.update(options); + const value = this.model.get('value'); + this.readout.textContent = this.valueToString(value); + } + + /** + * Convert from value to exponent + * + * @param value the widget value + * @returns the log-value between the min/max exponents + */ + logCalc(value: number): number { const min = this.model.get('min'); const max = this.model.get('max'); - const value = this.model.get('value'); const base = this.model.get('base'); - let log_value = Math.log(value) / Math.log(base); if (log_value > max) { @@ -130,12 +141,33 @@ export class FloatLogSliderView extends BaseIntSliderView { } else if (log_value < min) { log_value = min; } - this.$slider.slider('option', 'value', log_value); - this.readout.textContent = this.valueToString(value); - if (this.model.get('value') !== value) { - this.model.set('value', value, { updated_view: this }); - this.touch(); - } + + return log_value; + } + + createSlider() { + noUiSlider.create(this.$slider, { + start: this.logCalc(this.model.get('value')), + range: { + min: this.model.get('min'), + max: this.model.get('max') + }, + step: this.model.get('step') ?? undefined, + orientation: this.model.get('orientation'), + format: { + from: (value: number) => value, + to: (value: number) => value + } + }); + + // Using noUiSlider's event handler + this.$slider.noUiSlider.on('update', (values: any, handle: any) => { + this.handleSliderChange(values, handle); + }); + + this.$slider.noUiSlider.on('end', (values: any, handle: any) => { + this.handleSliderChanged(values, handle); + }); } /** @@ -187,15 +219,15 @@ export class FloatLogSliderView extends BaseIntSliderView { /** * Called when the slider value is changing. */ - handleSliderChange(e: Event, ui: { value: any }): void { + handleSliderChange(values: number[], handle: number): void { const base = this.model.get('base'); - const actual_value = Math.pow(base, this._validate_slide_value(ui.value)); + const actual_value = Math.pow(base, this._validate_slide_value(values[0])); this.readout.textContent = this.valueToString(actual_value); // Only persist the value while sliding if the continuous_update // trait is set to true. if (this.model.get('continuous_update')) { - this.handleSliderChanged(e, ui); + this.handleSliderChanged(values, handle); } } @@ -205,18 +237,42 @@ export class FloatLogSliderView extends BaseIntSliderView { * Calling model.set will trigger all of the other views of the * model to update. */ - handleSliderChanged(e: Event, ui: { value: any }): void { + handleSliderChanged(values: number[], handle: number): void { + if (this._updating_slider) { + return; + } const base = this.model.get('base'); - const actual_value = Math.pow(base, this._validate_slide_value(ui.value)); + const actual_value = Math.pow(base, this._validate_slide_value(values[0])); this.model.set('value', actual_value, { updated_view: this }); this.touch(); } - _validate_slide_value(x: any): any { + updateSliderValue(model: any, value: any, options: any): void { + if (options.updated_view === this) { + console.log('this view triggered'); + return; + } + const log_value = this.logCalc(this.model.get('value')); + this.$slider.noUiSlider.set(log_value); + } + + updateSliderOptions(e: any) { + this.$slider.noUiSlider.updateOptions({ + start: this.logCalc(this.model.get('value')), + range: { + min: this.model.get('min'), + max: this.model.get('max') + }, + step: this.model.get('step') + }); + } + + _validate_slide_value(x: any) { return x; } _parse_value = parseFloat; + private _updating_slider: boolean; } export class FloatRangeSliderView extends IntRangeSliderView { diff --git a/packages/controls/src/widget_int.ts b/packages/controls/src/widget_int.ts index 005c5b5da4..7c6a28c3db 100644 --- a/packages/controls/src/widget_int.ts +++ b/packages/controls/src/widget_int.ts @@ -11,8 +11,7 @@ import { uuid } from './utils'; import { format } from 'd3-format'; -import $ from 'jquery'; -import 'jquery-ui/ui/widgets/slider'; +import noUiSlider from 'nouislider'; export class IntModel extends CoreDescriptionModel { defaults(): Backbone.ObjectHash { @@ -89,16 +88,14 @@ export abstract class BaseIntSliderView extends DescriptionView { this.el.classList.add('widget-slider'); this.el.classList.add('widget-hslider'); - (this.$slider = $('
') as any) - .slider({ - slide: this.handleSliderChange.bind(this), - stop: this.handleSliderChanged.bind(this) - }) - .addClass('slider'); + // Creating noUiSlider instance and scaffolding + this.$slider = document.createElement('div'); + this.$slider.classList.add('slider'); + // Put the slider in a container this.slider_container = document.createElement('div'); this.slider_container.classList.add('slider-container'); - this.slider_container.appendChild(this.$slider[0]); + this.slider_container.appendChild(this.$slider); this.el.appendChild(this.slider_container); this.readout = document.createElement('div'); this.el.appendChild(this.readout); @@ -106,59 +103,39 @@ export abstract class BaseIntSliderView extends DescriptionView { this.readout.contentEditable = 'true'; this.readout.style.display = 'none'; + // noUiSlider constructor and event handlers + this.createSlider(); + + // Event handlers + this.model.on('change:orientation', this.regenSlider, this); + this.model.on('change:max', this.updateSliderOptions, this); + this.model.on('change:min', this.updateSliderOptions, this); + this.model.on('change:step', this.updateSliderOptions, this); + this.model.on('change:value', this.updateSliderValue, this); + // Set defaults. this.update(); } - update(options?: any): void { - /** - * Update the contents of this view - * - * Called when the model is changed. The model may have been - * changed by another view or by a state update from the back-end. - */ + /** + * Update the contents of this view + * + * Called when the model is changed. The model may have been + * changed by another view or by a state update from the back-end. + */ + update(options?: any) { if (options === undefined || options.updated_view !== this) { - // JQuery slider option keys. These keys happen to have a - // one-to-one mapping with the corresponding keys of the model. - const jquery_slider_keys = ['step', 'disabled']; - this.$slider.slider({}); - - jquery_slider_keys.forEach(key => { - const model_value = this.model.get(key); - if (model_value !== undefined) { - this.$slider.slider('option', key, model_value); - } - }); - if (this.model.get('disabled')) { this.readout.contentEditable = 'false'; + this.$slider.setAttribute('disabled', true); } else { this.readout.contentEditable = 'true'; + this.$slider.removeAttribute('disabled'); } - const max = this.model.get('max'); - const min = this.model.get('min'); - if (min <= max) { - if (max !== undefined) { - this.$slider.slider('option', 'max', max); - } - if (min !== undefined) { - this.$slider.slider('option', 'min', min); - } - } - - // WORKAROUND FOR JQUERY SLIDER BUG. - // The horizontal position of the slider handle - // depends on the value of the slider at the time - // of orientation change. Before applying the new - // workaround, we set the value to the minimum to - // make sure that the horizontal placement of the - // handle in the vertical slider is always - // consistent. + // Use the right CSS classes for vertical & horizontal sliders const orientation = this.model.get('orientation'); - this.$slider.slider('option', 'orientation', orientation); - // Use the right CSS classes for vertical & horizontal sliders if (orientation === 'vertical') { this.el.classList.remove('widget-hslider'); this.el.classList.add('widget-vslider'); @@ -208,8 +185,6 @@ export abstract class BaseIntSliderView extends DescriptionView { events(): { [e: string]: string } { return { // Dictionary of events and their handlers. - slide: 'handleSliderChange', - slidestop: 'handleSliderChanged', 'blur [contentEditable=true]': 'handleTextChange', 'keydown [contentEditable=true]': 'handleKeyDown' }; @@ -223,6 +198,60 @@ export abstract class BaseIntSliderView extends DescriptionView { } } + /** + * Create a new noUiSlider object + */ + createSlider() { + noUiSlider.create(this.$slider, { + animate: true, + start: this.model.get('value'), + connect: true, + range: { + min: this.model.get('min'), + max: this.model.get('max') + }, + step: this.model.get('step'), + orientation: this.model.get('orientation'), + format: { + from: (value: number) => value, + to: (value: number) => value + } + }); + + // Using noUiSlider's event handler + this.$slider.noUiSlider.on('update', (values: any, handle: any) => { + this.handleSliderChange(values, handle); + }); + + this.$slider.noUiSlider.on('end', (values: any, handle: any) => { + this.handleSliderChanged(values, handle); + }); + } + + /** + * Recreate/Regenerate a slider object + * noUiSlider does not support in-place mutation of the orientation + * state. We therefore need to destroy the current instance + * and create a new one with the new properties. This is + * handled in a separate function and has a dedicated event + * handler. + */ + regenSlider(e: any) { + this.$slider.noUiSlider.destroy(); + this.createSlider(); + } + + /** + * Update noUiSlider object in-place with new options + */ + abstract updateSliderOptions(e: any): void; + + /** + * Update noUiSlider's state so that it is + * synced with the Backbone.jas model + */ + abstract updateSliderValue(model: any, value: any, options: any): void; + /** * this handles the entry of text into the contentEditable label first, the * value is checked if it contains a parseable value then it is clamped @@ -236,10 +265,7 @@ export abstract class BaseIntSliderView extends DescriptionView { /** * Called when the slider value is changing. */ - abstract handleSliderChange( - e: any, - ui: { value?: number; values?: number[] } - ): void; + abstract handleSliderChange(value: any, handle: any): void; /** * Called when the slider value has changed. @@ -247,10 +273,7 @@ export abstract class BaseIntSliderView extends DescriptionView { * Calling model.set will trigger all of the other views of the * model to update. */ - abstract handleSliderChanged( - e: Event, - ui: { value?: number; values?: number[] } - ): void; + abstract handleSliderChanged(values: number[], handle: number): void; /** * Validate the value of the slider before sending it to the back-end @@ -268,13 +291,9 @@ export abstract class BaseIntSliderView extends DescriptionView { } export class IntRangeSliderView extends BaseIntSliderView { - update(options?: any): void { + update(options?: any) { super.update(options); - this.$slider.slider('option', 'range', true); - // values for the range case are validated python-side in - // _Bounded{Int,Float}RangeWidget._validate const value = this.model.get('value'); - this.$slider.slider('option', 'values', value.slice()); this.readout.textContent = this.valueToString(value); if (this.model.get('value') !== value) { this.model.set('value', value, { updated_view: this }); @@ -310,14 +329,6 @@ export class IntRangeSliderView extends BaseIntSliderView { } } - /** - * this handles the entry of text into the contentEditable label first, the - * value is checked if it contains a parseable value then it is clamped - * within the min-max range of the slider finally, the model is updated if - * the value is to be changed - * - * if any of these conditions are not met, the text is reset - */ handleTextChange(): void { let value = this.stringToValue(this.readout.textContent); const vmin = this.model.get('min'); @@ -349,32 +360,43 @@ export class IntRangeSliderView extends BaseIntSliderView { } } } - /** - * Called when the slider value is changing. - */ - handleSliderChange(e: any, ui: { values: number[] }): void { - const actual_value = ui.values.map(this._validate_slide_value); + + handleSliderChange(values: any, handle: any) { + const actual_value = values.map(this._validate_slide_value); this.readout.textContent = this.valueToString(actual_value); // Only persist the value while sliding if the continuous_update // trait is set to true. if (this.model.get('continuous_update')) { - this.handleSliderChanged(e, ui); + this.handleSliderChanged(values, handle); } } - /** - * Called when the slider value has changed. - * - * Calling model.set will trigger all of the other views of the - * model to update. - */ - handleSliderChanged(e: Event, ui: { values: number[] }): void { - const actual_value = ui.values.map(this._validate_slide_value); + handleSliderChanged(values: number[], handle: number) { + const actual_value = values.map(this._validate_slide_value); this.model.set('value', actual_value, { updated_view: this }); this.touch(); } + updateSliderOptions(e: any) { + this.$slider.noUiSlider.updateOptions({ + start: this.model.get('value'), + range: { + min: this.model.get('min'), + max: this.model.get('max') + }, + step: this.model.get('step') + }); + } + + updateSliderValue(e: any) { + const prev_value = this.$slider.noUiSlider.get(); + const value = this.model.get('value'); + if (prev_value[0] !== value[0] || prev_value[1] !== value[1]) { + this.$slider.noUiSlider.set(value); + } + } + // range numbers can be separated by a hyphen, colon, or an en-dash _range_regex = /^\s*([+-]?\d+)\s*[-:–]\s*([+-]?\d+)/; } @@ -391,7 +413,7 @@ export class IntSliderView extends BaseIntSliderView { } else if (value < min) { value = min; } - this.$slider.slider('option', 'value', value); + this.readout.textContent = this.valueToString(value); if (this.model.get('value') !== value) { this.model.set('value', value, { updated_view: this }); @@ -399,31 +421,17 @@ export class IntSliderView extends BaseIntSliderView { } } - /** - * Write value to a string - */ - valueToString(value: number): string { + valueToString(value: number | number[]): string { const format = this.model.readout_formatter; return format(value); } - /** - * Parse value from a string - */ - stringToValue(text: string | null): number { - return text === null ? NaN : this._parse_value(text); + stringToValue(text: string): number | number[] { + return this._parse_value(text); } - /** - * this handles the entry of text into the contentEditable label first, the - * value is checked if it contains a parseable value then it is clamped - * within the min-max range of the slider finally, the model is updated if - * the value is to be changed - * - * if any of these conditions are not met, the text is reset - */ handleTextChange(): void { - let value = this.stringToValue(this.readout.textContent); + let value = this.stringToValue(this.readout.textContent ?? ''); const vmin = this.model.get('min'); const vmax = this.model.get('max'); @@ -441,30 +449,45 @@ export class IntSliderView extends BaseIntSliderView { } } } - /** - * Called when the slider value is changing. - */ - handleSliderChange(e: any, ui: { value: number }): void { - const actual_value = this._validate_slide_value(ui.value); + + handleSliderChange(values: any, handle: any) { + const actual_value = this._validate_slide_value(values[handle]); this.readout.textContent = this.valueToString(actual_value); // Only persist the value while sliding if the continuous_update // trait is set to true. if (this.model.get('continuous_update')) { - this.handleSliderChanged(e, ui); + this.handleSliderChanged(values, handle); } } - /** - * Called when the slider value has changed. - * - * Calling model.set will trigger all of the other views of the - * model to update. - */ - handleSliderChanged(e: Event, ui: { value?: any }): void { - const actual_value = this._validate_slide_value(ui.value); - this.model.set('value', actual_value, { updated_view: this }); - this.touch(); + handleSliderChanged(values: any, handle: any) { + const actual_value = this._validate_slide_value(values[handle]); + const model_value = this.model.get('value'); + + if (parseFloat(model_value) !== actual_value) { + this.model.set('value', actual_value, { updated_view: this }); + this.touch(); + } + } + + updateSliderOptions(e: any): void { + this.$slider.noUiSlider.updateOptions({ + start: this.model.get('value'), + range: { + min: this.model.get('min'), + max: this.model.get('max') + }, + step: this.model.get('step') + }); + } + + updateSliderValue(e: any): void { + const prev_value = this.$slider.noUiSlider.get(); + const value = this.model.get('value'); + if (prev_value !== value) { + this.$slider.noUiSlider.set(value); + } } } diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index 78e858a627..ae24d353b8 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -10,7 +10,7 @@ import { DescriptionView, DescriptionStyleModel } from './widget_description'; import { uuid } from './utils'; import * as utils from './utils'; -import $ from 'jquery'; +import noUiSlider from 'nouislider'; export class SelectionModel extends CoreDescriptionModel { defaults(): Backbone.ObjectHash { @@ -666,17 +666,14 @@ export class SelectionSliderView extends DescriptionView { this.el.classList.add('widget-hslider'); this.el.classList.add('widget-slider'); - (this.$slider = $('
') as any) - .slider({ - slide: this.handleSliderChange.bind(this), - stop: this.handleSliderChanged.bind(this) - }) - .addClass('slider'); + // Creating noUiSlider instance and scaffolding + this.$slider = document.createElement('div'); + this.$slider.classList.add('slider'); // Put the slider in a container this.slider_container = document.createElement('div'); this.slider_container.classList.add('slider-container'); - this.slider_container.appendChild(this.$slider[0]); + this.slider_container.appendChild(this.$slider); this.el.appendChild(this.slider_container); this.readout = document.createElement('div'); @@ -684,11 +681,12 @@ export class SelectionSliderView extends DescriptionView { this.readout.classList.add('widget-readout'); this.readout.style.display = 'none'; - this.listenTo(this.model, 'change:slider_color', (sender, value) => { - this.$slider.find('a').css('background', value); - }); + // noUiSlider constructor and event handlers + this.createSlider(); - this.$slider.find('a').css('background', this.model.get('slider_color')); + // Event handlers + this.model.on('change:orientation', this.regenSlider, this); + this.model.on('change:index', this.updateSliderValue, this); // Set defaults. this.update(); @@ -702,31 +700,15 @@ export class SelectionSliderView extends DescriptionView { */ update(options?: any): void { if (options === undefined || options.updated_view !== this) { - const labels = this.model.get('_options_labels'); - const max = labels.length - 1; - const min = 0; - this.$slider.slider('option', 'step', 1); - this.$slider.slider('option', 'max', max); - this.$slider.slider('option', 'min', min); - - // WORKAROUND FOR JQUERY SLIDER BUG. - // The horizontal position of the slider handle - // depends on the value of the slider at the time - // of orientation change. Before applying the new - // workaround, we set the value to the minimum to - // make sure that the horizontal placement of the - // handle in the vertical slider is always - // consistent. + this.updateSliderOptions(this.model); const orientation = this.model.get('orientation'); - this.$slider.slider('option', 'value', min); - this.$slider.slider('option', 'orientation', orientation); - const disabled = this.model.get('disabled'); - this.$slider.slider('option', 'disabled', disabled); if (disabled) { this.readout.contentEditable = 'false'; + this.$slider.setAttribute('disabled', true); } else { this.readout.contentEditable = 'true'; + this.$slider.removeAttribute('disabled'); } // Use the right CSS classes for vertical & horizontal sliders @@ -755,6 +737,42 @@ export class SelectionSliderView extends DescriptionView { return super.update(options); } + regenSlider(e: any) { + this.$slider.noUiSlider.destroy(); + this.createSlider(); + } + + createSlider() { + const labels = this.model.get('_options_labels'); + const min = 0; + const max = labels.length - 1; + + noUiSlider.create(this.$slider, { + animate: true, + start: this.model.get('index'), + connect: true, + range: { + min: min, + max: max + }, + step: 1, + orientation: this.model.get('orientation'), + format: { + from: (value: number) => value, + to: (value: number) => value + } + }); + + // Using noUiSlider's event handler + this.$slider.noUiSlider.on('update', (values: number[], handle: number) => { + this.handleSliderChange(values, handle); + }); + + this.$slider.noUiSlider.on('end', (values: number[], handle: number) => { + this.handleSliderChanged(values, handle); + }); + } + events(): { [e: string]: string } { return { slide: 'handleSliderChange', @@ -764,7 +782,6 @@ export class SelectionSliderView extends DescriptionView { updateSelection(): void { const index = this.model.get('index'); - this.$slider.slider('option', 'value', index); this.updateReadout(index); } @@ -776,16 +793,14 @@ export class SelectionSliderView extends DescriptionView { /** * Called when the slider value is changing. */ - handleSliderChange( - e: Event, - ui: { value?: number; values?: number[] } - ): void { - this.updateReadout(ui.value); + handleSliderChange(values: number[], handle: number): void { + const index = values[0]; + this.updateReadout(index); // Only persist the value while sliding if the continuous_update // trait is set to true. if (this.model.get('continuous_update')) { - this.handleSliderChanged(e, ui); + this.handleSliderChanged(values, handle); } } @@ -795,15 +810,36 @@ export class SelectionSliderView extends DescriptionView { * Calling model.set will trigger all of the other views of the * model to update. */ - handleSliderChanged( - e: Event, - ui: { value?: number; values?: number[] } - ): void { - this.updateReadout(ui.value); - this.model.set('index', ui.value, { updated_view: this }); + handleSliderChanged(values: number[], handle: number) { + const index = values[0]; + this.updateReadout(index); + this.model.set('index', index, { updated_view: this }); this.touch(); } + updateSliderOptions(e: any) { + const labels = this.model.get('_options_labels'); + const min = 0; + const max = labels.length - 1; + + this.$slider.noUiSlider.updateOptions({ + start: this.model.get('index'), + range: { + min: min, + max: max + }, + step: 1 + }); + } + + updateSliderValue(e: any) { + const prev_index = this.$slider.noUiSlider.get(); + const index = this.model.get('index'); + if (prev_index !== index) { + this.$slider.noUiSlider.set(index); + } + } + $slider: any; slider_container: HTMLDivElement; readout: HTMLDivElement; @@ -891,12 +927,10 @@ export class SelectionRangeSliderView extends SelectionSliderView { */ render(): void { super.render(); - this.$slider.slider('option', 'range', true); } updateSelection(): void { const index = this.model.get('index'); - this.$slider.slider('option', 'values', index.slice()); this.updateReadout(index); } @@ -910,13 +944,14 @@ export class SelectionRangeSliderView extends SelectionSliderView { /** * Called when the slider value is changing. */ - handleSliderChange(e: Event, ui: { values: number[] }): void { - this.updateReadout(ui.values); + handleSliderChange(values: number[], handle: any) { + const intValues = values.map(Math.trunc); + this.updateReadout(intValues); // Only persist the value while sliding if the continuous_update // trait is set to true. if (this.model.get('continuous_update')) { - this.handleSliderChanged(e, ui); + this.handleSliderChanged(values, handle); } } @@ -926,14 +961,25 @@ export class SelectionRangeSliderView extends SelectionSliderView { * Calling model.set will trigger all of the other views of the * model to update. */ - handleSliderChanged(e: Event, ui: { values: number[] }): void { - // The jqueryui documentation indicates ui.values doesn't exist on the slidestop event, - // but it appears that it actually does: https://github.com/jquery/jquery-ui/blob/ae31f2b3b478975f70526bdf3299464b9afa8bb1/ui/widgets/slider.js#L313 - this.updateReadout(ui.values); - this.model.set('index', ui.values.slice(), { updated_view: this }); + handleSliderChanged(values: number[], handle: number) { + const intValues = values.map(Math.round); + this.updateReadout(intValues); + + // set index to a snapshot of the values passed by the slider + this.model.set('index', intValues.slice(), { updated_view: this }); this.touch(); } + updateSliderValue(e: any) { + // Rounding values to avoid floating point precision error for the if statement below + const prev_index = this.$slider.noUiSlider.get().map(Math.round); + const index = this.model.get('index').map(Math.round); + + if (prev_index[0] !== index[0] || prev_index[1] !== index[1]) { + this.$slider.noUiSlider.set(index); + } + } + $slider: any; slider_container: HTMLDivElement; readout: HTMLDivElement; diff --git a/yarn.lock b/yarn.lock index 5f8424ac5d..7997297a2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1631,6 +1631,13 @@ resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/nouislider@^9.0.6": + version "9.0.6" + resolved "https://registry.npmjs.org/@types/nouislider/-/nouislider-9.0.6.tgz#d053f58c58a7044698d4eaeffc5c3a4443117806" + integrity sha512-ASkRBlWX5qiAucW08jS2/ZGKJQWqx/JWJ52fU7LCLRRtfOaOCqYsZmBYJdIG3j+MRX2ZSaB0nglFujPGckLn4Q== + dependencies: + "@types/wnumb" "*" + "@types/prop-types@*": version "15.7.3" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -1679,6 +1686,11 @@ resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.9.4.tgz#22d1a3e6b494608e430221ec085fa0b7ccee7f33" integrity sha512-CjHWEMECc2/UxOZh0kpiz3lEyX2Px3rQS9HzD20lxMvx571ivOBQKeLnqEjxUY0BMgp6WJWo/pQLRBwMW5v4WQ== +"@types/wnumb@*": + version "1.2.0" + resolved "https://registry.npmjs.org/@types/wnumb/-/wnumb-1.2.0.tgz#de93478a0d10ea2e27809b5e3fc90ac52c1acd43" + integrity sha512-74+3MQqSTrDUtYisxrE8xbrTxmc3pi3G+yr1/TKNrMD7nfXIgUSgYeOu5R7g6Nsn/hl1GxKZ5VW2BuOQg9aLYg== + "@typescript-eslint/eslint-plugin@^2.14.0": version "2.14.0" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.14.0.tgz#c74447400537d4eb7aae1e31879ab43e6c662a8a" @@ -5911,11 +5923,6 @@ istanbul@^0.4.0: which "^1.1.1" wordwrap "^1.0.0" -jquery-ui@^1.12.1: - version "1.12.1" - resolved "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.12.1.tgz#bcb4045c8dd0539c134bc1488cdd3e768a7a9e51" - integrity sha1-vLQEXI3QU5wTS8FIjN0+dop6nlE= - jquery@^3.1.1, jquery@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz#714f1f8d9dde4bdfa55764ba37ef214630d80ef2" @@ -7158,6 +7165,11 @@ normalize.css@^8.0.1: resolved "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== +nouislider@^14.1.1: + version "14.1.1" + resolved "https://registry.npmjs.org/nouislider/-/nouislider-14.1.1.tgz#def812b2aaaa2ccf9e7a41dd0144a25dab5673e5" + integrity sha512-3/+Z/pTBoWoJf2YXSEWRmS27LW2XxOBmGEzkPyRzB/J6QvL+0mS3QwcQp0SmWhgO5CMzbSxPmb1lDDD4HP12bg== + npm-bundled@^1.0.1: version "1.1.1" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"