1// Copyright (c) 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * @fileoverview A base class for scrollbar-like controls.
9 */
10base.require('ui');
11base.require('base.properties');
12base.require('ui.mouse_tracker');
13
14base.requireStylesheet('ui.value_bar');
15
16base.exportTo('ui', function() {
17
18  /**
19   * @constructor
20   */
21  var ValueBar = ui.define('value-bar');
22
23  ValueBar.prototype = {
24    __proto__: HTMLDivElement.prototype,
25
26    decorate: function() {
27      this.className = 'value-bar';
28      this.lowestValueControl_ = this.createLowestValueControl_();
29      this.valueRangeControl_ = this.createValueRangeControl_();
30      this.highestValueControl_ = this.createHighestValueControl_();
31      this.valueSliderControl_ =
32          this.createValueSlider_(this.valueRangeControl_);
33
34      this.vertical = true;
35      this.exponentBase_ = 1.0;
36      this.lowestValue = 0.1;
37      this.highestValue = 2.0;
38      this.value = 0.5;
39    },
40
41    get lowestValue() {
42      return this.lowestValue_;
43    },
44
45    set lowestValue(newValue) {
46      base.setPropertyAndDispatchChange(this, 'lowestValue', newValue);
47    },
48
49    get value() {
50      return this.value_;
51    },
52
53    set value(newValue) {
54      if (newValue === this.value)
55        return;
56      newValue = this.limitValue_(newValue);
57      base.setPropertyAndDispatchChange(this, 'value', newValue);
58    },
59
60    // A value that changes when you mouseover slider.
61    get previewValue() {
62      return this.previewValue_;
63    },
64
65    set previewValue(newValue) {
66      if (newValue === this.previewValue_)
67        return;
68      newValue = this.limitValue_(newValue);
69      base.setPropertyAndDispatchChange(this, 'previewValue', newValue);
70    },
71
72    get highestValue() {
73      return this.highestValue_;
74    },
75
76    set highestValue(newValue) {
77      base.setPropertyAndDispatchChange(this, 'highestValue', newValue);
78    },
79
80    get vertical() {
81      return this.vertical_;
82    },
83
84    set vertical(newValue) {
85      this.vertical_ = !!newValue;
86      delete this.rangeControlOffset_;
87      delete this.rangeControlPixelRange_;
88      delete this.valueSliderCenterOffset_;
89      this.setAttribute('orient', this.vertical_ ? 'vertical' : 'horizontal');
90      base.setPropertyAndDispatchChange(this, 'value', this.value);
91    },
92
93    get exponentBase() {
94      return this.exponentBase_;
95    },
96
97    // Controls the amount of non-linearity in the value bar.
98    // Higher bases make changes at low value value slower
99    // and changes at high values faster.
100    set exponentBase(newValue) {
101      this.exponentBase_ = newValue;
102    },
103
104    // Override to change content.
105    updateLowestValueElement: function(element) {
106      element.removeAttribute('style');
107      var str = event.newValue.toFixed(1) + '';
108      element.textContent = str.substr(0, 3);
109    },
110
111    updateHighestValueElement: function(element) {
112      element.removeAttribute('style');
113      var str = event.newValue.toFixed(1) + '';
114      element.textContent = str.substr(0, 3);
115    },
116
117    get rangeControlOffset() {
118      if (!this.rangeControlOffset_) {
119        var rect = this.valueRangeControl_.getBoundingClientRect();
120        this.rangeControlOffset_ = this.vertical_ ? rect.top : rect.left;
121      }
122      return this.rangeControlOffset_;
123    },
124
125    get valueSlideCenterOffset() {
126      var offsetDirection = this.vertical_ ? 'offsetTop' : 'offsetLeft';
127      return this.valueSliderCenter_[offsetDirection] + 1;
128    },
129
130    get rangeControlPixelRange() {
131      if (!this.rangeControlPixelRange_ || this.rangeControlPixelRange_ < 1) {
132        var rangeRect = this.valueRangeControl_.getBoundingClientRect();
133        this.rangeControlPixelRange_ =
134            this.vertical_ ? rangeRect.height - 1 : rangeRect.width - 1;
135      }
136      return this.rangeControlPixelRange_;
137    },
138
139    // The value <--> pixel conversion formulas are all normalized to the
140    // range 0-1 to avoid overflow surprises. Three layers of normalization
141    // include:
142    // 1. pixel range of the valuebar
143    // 2. exponent/log of the normalized ranges
144    // 3. value range
145
146    // offset zero gives 0, offset rangeControlPixelRange_ gives 1,
147    // exponential in between.
148    fractionalValue_: function(offset) {
149      if (!this.rangeControlPixelRange)
150        return 0;
151      console.assert(offset >= 0);
152      // min offset is zero, so this ratio is (offset - min) / (max - min)
153      var fractionOfRange = offset / this.rangeControlPixelRange_;
154      if (fractionOfRange > 1)
155        fractionOfRange = 1.0;
156      if (this.exponentBase === 1)
157        return fractionOfRange;
158      // The - 1 terms are Math.pow(this.exponentBase_, 0) for the minimum
159      // pixel range of zero.
160      var numerator = Math.pow(this.exponentBase_, fractionOfRange) - 1;
161      return numerator / (this.exponentBase_ - 1);
162    },
163
164    // fractionalValue zero gives zero, 1.0 gives rangeControlPixelRange_
165    pixelByValue_: function(fractionalValue) {
166      console.assert(fractionalValue >= 0 && fractionalValue <= 1);
167      if (this.exponentBase_ === 1)
168        return this.rangeControlPixelRange * fractionalValue;
169
170      // fractionalValue *(this.exponentBase_^1 - this.exponentBase_^0) +
171      //   this.exponentBase_^0
172      var expPixel = fractionalValue * (this.exponentBase_ - 1) + 1;
173      var fractionalPixel = Math.log(expPixel) / Math.log(this.exponentBase_);
174      // (max - min) * fractionalPixel + min for min == 0
175      return this.rangeControlPixelRange * fractionalPixel;
176    },
177
178    limitValue_: function(newValue) {
179      var limitedValue = newValue;
180      if (newValue < this.lowestValue)
181        limitedValue = this.lowestValue;
182      if (newValue > this.highestValue)
183        limitedValue = this.highestValue;
184      return limitedValue;
185    },
186
187    eventToPixelOffset_: function(event) {
188      var coord = this.vertical_ ? 'y' : 'x';
189      var pixelOffset = event[coord] - this.rangeControlOffset;
190      return Math.max(pixelOffset, 1);
191    },
192
193    convertPixelOffsetToValue_: function(offset) {
194      var rangeInValue = this.highestValue - this.lowestValue;
195      return this.fractionalValue_(offset) * (rangeInValue) + this.lowestValue;
196    },
197
198    convertValueToPixelOffset: function(value) {
199      if (!this.highestValue)
200        return 0;
201      var rangeInValue = this.highestValue - this.lowestValue;
202      var valueInPx =
203          this.pixelByValue_((value - this.lowestValue) / rangeInValue);
204      return valueInPx;
205    },
206
207    setValueOnRangeClick_: function(event) {
208      var pixelOffset = this.eventToPixelOffset_(event);
209      this.value = this.convertPixelOffsetToValue_(pixelOffset);
210    },
211
212    setPreviewValueByEvent_: function(event) {
213      var pixelOffset = this.eventToPixelOffset_(event);
214      if (event.currentTarget.classList.contains('lowest-value-control'))
215        pixelOffset = 0; // There is a 4 pixel error on the bottom of the range.
216      this.previewValue = this.convertPixelOffsetToValue_(pixelOffset);
217    },
218    /**
219      @param {Event} event: mouse event relative to slider control
220    */
221    slideStart_: function(event) {
222      this.slideStart_ = event;
223    },
224    /**
225      @param {Event} event: mouse event relative to slider control
226    */
227    slideValue_: function(event) {
228      var pixelOffset = this.eventToPixelOffset_(event);
229      this.value =
230          this.convertPixelOffsetToValue_(pixelOffset);
231    },
232
233    slideEnd_: function(event) {
234      this.preview = this.value;
235    },
236
237    onValueChange_: function(valueKey) {
238      var pixelOffset = this.convertValueToPixelOffset(this[valueKey]);
239      pixelOffset = pixelOffset - this.valueSlideCenterOffset;
240      if (this.vertical_) {
241        this.valueSliderControl_.style.left = 0;
242        this.valueSliderControl_.style.top = pixelOffset + 'px';
243      } else {
244        this.valueSliderControl_.style.left = pixelOffset + 'px';
245        this.valueSliderControl_.style.top = 0;
246      }
247    },
248
249    createValueControl_: function(className) {
250      return ui.createDiv({
251        className: className + ' value-control',
252        parent: this
253      });
254    },
255
256    createLowestValueControl_: function() {
257      var lowestValueControl = this.createValueControl_('lowest-value-control');
258
259      lowestValueControl.addEventListener('click', function() {
260        this.value = this.lowestValue;
261        base.dispatchSimpleEvent(this, 'lowestValueClick');
262      }.bind(this));
263      lowestValueControl.addEventListener('mouseover',
264          this.setPreviewValueByEvent_.bind(this));
265
266      // Interior element to control the whitespace around the button text
267      var lowestValueControlContent =
268          ui.createSpan({className: 'lowest-value-control-content'});
269      lowestValueControl.appendChild(lowestValueControlContent);
270
271      this.addEventListener('lowestValueChange', function(event) {
272        this.updateLowestValueElement(lowestValueControlContent);
273      }.bind(this));
274
275      return lowestValueControl;
276    },
277
278    createValueRangeControl_: function() {
279      var valueRangeControl = this.createValueControl_('value-range-control');
280      // As the user moves over our range control, preview the result.
281      valueRangeControl.addEventListener('mousemove',
282          this.setPreviewValueByEvent_.bind(this));
283      // Accept the current value.
284      valueRangeControl.addEventListener('click',
285          this.setValueOnRangeClick_.bind(this));
286      this.addEventListener('valueChange',
287          this.onValueChange_.bind(this, 'value'), true);
288      return valueRangeControl;
289    },
290
291    createHighestValueControl_: function() {
292      var highestValueControl =
293          this.createValueControl_('highest-value-control');
294      highestValueControl.addEventListener('click', function() {
295        this.value = this.highestValue;
296        base.dispatchSimpleEvent(this, 'highestValueClick');
297      }.bind(this));
298      var highestValueControlContent =
299          ui.createSpan({className: 'highest-value-control-content'});
300      highestValueControl.appendChild(highestValueControlContent);
301      this.addEventListener('highestValueChange', function(event) {
302        this.updateHighestValueElement(highestValueControlContent);
303      }.bind(this));
304      return highestValueControl;
305    },
306
307    createValueSlider_: function(rangeControl) {
308      var valueSlider = ui.createDiv({
309        className: 'value-slider',
310        parent: rangeControl
311      });
312      ui.createDiv({
313        className: 'value-slider-top',
314        parent: valueSlider
315      });
316      this.valueSliderCenter_ = ui.createDiv({
317        className: 'value-slider-bottom',
318        parent: valueSlider
319      });
320
321      this.mouseTracker = new ui.MouseTracker(valueSlider);
322      valueSlider.addEventListener('mouse-tracker-start',
323          this.slideStart_.bind(this));
324      valueSlider.addEventListener('mouse-tracker-move',
325          this.slideValue_.bind(this));
326      valueSlider.addEventListener('mouse-tracker-end',
327          this.slideEnd_.bind(this));
328      return valueSlider;
329    },
330
331  };
332
333  return {
334    ValueBar: ValueBar
335  };
336});
337