1// Copyright 2014 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(function(exports) {
5  /**
6   * Alignment options for a keyset.
7   * @param {Object=} opt_keyset The keyset to calculate the dimensions for.
8   *    Defaults to the current active keyset.
9   */
10  var AlignmentOptions = function(opt_keyset) {
11    var keyboard = document.getElementById('keyboard');
12    var keyset = opt_keyset || keyboard.activeKeyset;
13    this.calculate(keyset);
14  }
15
16  AlignmentOptions.prototype = {
17    /**
18     * The width of a regular key in logical pixels.
19     * @type {number}
20     */
21    keyWidth: 0,
22
23    /**
24     * The horizontal space between two keys in logical pixels.
25     * @type {number}
26     */
27    pitchX: 0,
28
29    /**
30     * The vertical space between two keys in logical pixels.
31     * @type {number}
32     */
33    pitchY: 0,
34
35    /**
36     * The width in logical pixels the row should expand within.
37     * @type {number}
38     */
39    availableWidth: 0,
40
41    /**
42     * The x-coordinate in logical pixels of the left most edge of the keyset.
43     * @type {number}
44     */
45    offsetLeft: 0,
46
47    /**
48     * The x-coordinate of the right most edge in logical pixels of the keyset.
49     * @type {number}
50     */
51    offsetRight: 0,
52
53    /**
54     * The height in logical pixels of all keys.
55     * @type {number}
56     */
57    keyHeight: 0,
58
59    /**
60     * The height in logical pixels the keyset should stretch to fit.
61     * @type {number}
62     */
63    availableHeight: 0,
64
65    /**
66     * The y-coordinate in logical pixels of the top most edge of the keyset.
67     * @type {number}
68     */
69    offsetTop: 0,
70
71    /**
72     * The y-coordinate in logical pixels of the bottom most edge of the keyset.
73     * @type {number}
74     */
75    offsetBottom: 0,
76
77    /**
78     * The ideal width of the keyboard container.
79     * @type {number}
80     */
81    width: 0,
82
83    /**
84     * The ideal height of the keyboard container.
85     * @type {number}
86     */
87    height: 0,
88
89    /**
90     * Recalculates the alignment options for a specific keyset.
91     * @param {Object} keyset The keyset to align.
92     */
93    calculate: function (keyset) {
94      var rows = keyset.querySelectorAll('kb-row').array();
95      // Pick candidate row. This is the row with the most keys.
96      var row = rows[0];
97      var candidateLength = rows[0].childElementCount;
98      for (var i = 1; i < rows.length; i++) {
99        if (rows[i].childElementCount > candidateLength &&
100            rows[i].align == RowAlignment.STRETCH) {
101          row = rows[i];
102          candidateLength = rows[i].childElementCount;
103        }
104      }
105      var allKeys = row.children;
106
107      // Calculates widths first.
108      // Weight of a single interspace.
109      var pitches = keyset.pitch.split();
110      var pitchWeightX;
111      var pitchWeightY;
112      pitchWeightX = parseFloat(pitches[0]);
113      pitchWeightY = pitches.length < 2 ? pitchWeightX : parseFloat(pitch[1]);
114
115      // Sum of all keys in the current row.
116      var keyWeightSumX = 0;
117      for (var i = 0; i < allKeys.length; i++) {
118        keyWeightSumX += allKeys[i].weight;
119      }
120
121      var interspaceWeightSumX = (allKeys.length -1) * pitchWeightX;
122      // Total weight of the row in X.
123      var totalWeightX = keyWeightSumX + interspaceWeightSumX +
124          keyset.weightLeft + keyset.weightRight;
125      var keyAspectRatio = getKeyAspectRatio();
126      var totalWeightY = (pitchWeightY * (rows.length - 1)) +
127                         keyset.weightTop +
128                         keyset.weightBottom;
129      for (var i = 0; i < rows.length; i++) {
130        totalWeightY += rows[i].weight / keyAspectRatio;
131      }
132      // Calculate width and height of the window.
133      var bounds = exports.getKeyboardBounds();
134
135      this.width = bounds.width;
136      this.height = bounds.height;
137      var pixelPerWeightX = bounds.width/totalWeightX;
138      var pixelPerWeightY = bounds.height/totalWeightY;
139
140      if (keyset.align == LayoutAlignment.CENTER) {
141        if (totalWeightX/bounds.width < totalWeightY/bounds.height) {
142          pixelPerWeightY = bounds.height/totalWeightY;
143          pixelPerWeightX = pixelPerWeightY;
144          this.width = Math.floor(pixelPerWeightX * totalWeightX)
145        } else {
146          pixelPerWeightX = bounds.width/totalWeightX;
147          pixelPerWeightY = pixelPerWeightX;
148          this.height = Math.floor(pixelPerWeightY * totalWeightY);
149        }
150      }
151      // Calculate pitch.
152      this.pitchX = Math.floor(pitchWeightX * pixelPerWeightX);
153      this.pitchY = Math.floor(pitchWeightY * pixelPerWeightY);
154
155      // Convert weight to pixels on x axis.
156      this.keyWidth = Math.floor(DEFAULT_KEY_WEIGHT * pixelPerWeightX);
157      var offsetLeft = Math.floor(keyset.weightLeft * pixelPerWeightX);
158      var offsetRight = Math.floor(keyset.weightRight * pixelPerWeightX);
159      this.availableWidth = this.width - offsetLeft - offsetRight;
160
161      // Calculates weight to pixels on the y axis.
162      var weightY = Math.floor(DEFAULT_KEY_WEIGHT / keyAspectRatio);
163      this.keyHeight = Math.floor(weightY * pixelPerWeightY);
164      var offsetTop = Math.floor(keyset.weightTop * pixelPerWeightY);
165      var offsetBottom = Math.floor(keyset.weightBottom * pixelPerWeightY);
166      this.availableHeight = this.height - offsetTop - offsetBottom;
167
168      var dX = bounds.width - this.width;
169      this.offsetLeft = offsetLeft + Math.floor(dX/2);
170      this.offsetRight = offsetRight + Math.ceil(dX/2)
171
172      var dY = bounds.height - this.height;
173      this.offsetBottom = offsetBottom + dY;
174      this.offsetTop = offsetTop;
175    },
176  };
177
178  /**
179   * A simple binary search.
180   * @param {Array} array The array to search.
181   * @param {number} start The start index.
182   * @param {number} end The end index.
183   * @param {Function<Object>:number} The test function used for searching.
184   * @private
185   * @return {number} The index of the search, or -1 if it was not found.
186   */
187  function binarySearch_(array, start, end, testFn) {
188      if (start > end) {
189        // No match found.
190        return -1;
191      }
192      var mid = Math.floor((start+end)/2);
193      var result = testFn(mid);
194      if (result == 0)
195        return mid;
196      if (result < 0)
197        return binarySearch_(array, start, mid - 1, testFn);
198      else
199        return binarySearch_(array, mid + 1, end, testFn);
200  }
201
202  /**
203   * Calculate width and height of the window.
204   * @private
205   * @return {Array.<String, number>} The bounds of the keyboard container.
206   */
207  function getKeyboardBounds_() {
208    return {
209      "width": screen.width,
210      "height": screen.height * DEFAULT_KEYBOARD_ASPECT_RATIO
211    };
212  }
213
214  /**
215   * Calculates the desired key aspect ratio based on screen size.
216   * @return {number} The aspect ratio to use.
217   */
218  function getKeyAspectRatio() {
219    return (screen.width > screen.height) ?
220        KEY_ASPECT_RATIO_LANDSCAPE : KEY_ASPECT_RATIO_PORTRAIT;
221  }
222
223  /**
224   * Callback function for when the window is resized.
225   */
226  var onResize = function() {
227    var keyboard = $('keyboard');
228    keyboard.stale = true;
229    var keyset = keyboard.activeKeyset;
230    if (keyset)
231      realignAll();
232  };
233
234  /**
235   * Updates a specific key to the position specified.
236   * @param {Object} key The key to update.
237   * @param {number} width The new width of the key.
238   * @param {number} height The new height of the key.
239   * @param {number} left The left corner of the key.
240   * @param {number} top The top corner of the key.
241   */
242  function updateKey(key, width, height, left, top) {
243    key.style.position = 'absolute';
244    key.style.width = width + 'px';
245    key.style.height = (height - KEY_PADDING_TOP - KEY_PADDING_BOTTOM) + 'px';
246    key.style.left = left + 'px';
247    key.style.top = (top + KEY_PADDING_TOP) + 'px';
248  }
249
250  /**
251   * Returns the key closest to given x-coordinate
252   * @param {Array.<kb-key>} allKeys Sorted array of all possible key
253   *     candidates.
254   * @param {number} x The x-coordinate.
255   * @param {number} pitch The pitch of the row.
256   * @param {boolean} alignLeft whether to search with respect to the left or
257   *   or right edge.
258   * @return {?kb-key}
259   */
260  function findClosestKey(allKeys, x, pitch, alignLeft) {
261    // Test function.
262    var testFn = function(i) {
263      var ERROR_THRESH = 1;
264      var key = allKeys[i];
265      var left = parseFloat(key.style.left);
266      if (!alignLeft)
267        left += parseFloat(key.style.width);
268      var deltaRight = 0.5*(parseFloat(key.style.width) + pitch)
269      deltaLeft = 0.5 * pitch;
270      if (i > 0)
271        deltaLeft += 0.5*parseFloat(allKeys[i-1].style.width);
272      var high = Math.ceil(left + deltaRight) + ERROR_THRESH;
273      var low = Math.floor(left - deltaLeft) - ERROR_THRESH;
274      if (x <= high && x >= low)
275        return 0;
276      return x >= high? 1 : -1;
277    }
278    var index = exports.binarySearch(allKeys, 0, allKeys.length -1, testFn);
279    return index > 0 ? allKeys[index] : null;
280  }
281
282  /**
283   * Redistributes the total width amongst the keys in the range provided.
284   * @param {Array.<kb-key>} allKeys Ordered list of keys to stretch.
285   * @param {AlignmentOptions} params Options for aligning the keyset.
286   * @param {number} xOffset The x-coordinate of the key who's index is start.
287   * @param {number} width The total extraneous width to distribute.
288   * @param {number} keyHeight The height of each key.
289   * @param {number} yOffset The y-coordinate of the top edge of the row.
290   */
291  function redistribute(allKeys, params, xOffset, width, keyHeight, yOffset) {
292    var availableWidth = width - (allKeys.length - 1) * params.pitchX;
293    var stretchWeight = 0;
294    var nStretch = 0;
295    for (var i = 0; i < allKeys.length; i++) {
296      var key = allKeys[i];
297      if (key.stretch) {
298        stretchWeight += key.weight;
299        nStretch++;
300      } else if (key.weight == DEFAULT_KEY_WEIGHT) {
301        availableWidth -= params.keyWidth;
302      } else {
303        availableWidth -=
304            Math.floor(key.weight/DEFAULT_KEY_WEIGHT * params.keyWidth);
305      }
306    }
307    if (stretchWeight <= 0)
308      console.error("Cannot stretch row without a stretchable key");
309    // Rounding error to distribute.
310    var pixelsPerWeight = availableWidth / stretchWeight;
311    for (var i = 0; i < allKeys.length; i++) {
312      var key = allKeys[i];
313      var keyWidth = params.keyWidth;
314      if (key.weight != DEFAULT_KEY_WEIGHT) {
315        keyWidth =
316            Math.floor(key.weight/DEFAULT_KEY_WEIGHT * params.keyWidth);
317      }
318      if (key.stretch) {
319        nStretch--;
320        if (nStretch > 0) {
321          keyWidth = Math.floor(key.weight * pixelsPerWeight);
322          availableWidth -= keyWidth;
323        } else {
324          keyWidth = availableWidth;
325        }
326      }
327      updateKey(key, keyWidth, keyHeight, xOffset, yOffset)
328      xOffset += keyWidth + params.pitchX;
329    }
330  }
331
332  /**
333   * Aligns a row such that the spacebar is perfectly aligned with the row above
334   * it. A precondition is that all keys in this row can be stretched as needed.
335   * @param {!kb-row} row The current row to be aligned.
336   * @param {!kb-row} prevRow The row above the current row.
337   * @param {!AlignmentOptions} params Options for aligning the keyset.
338   * @param {number} keyHeight The height of the keys in this row.
339   * @param {number} heightOffset The height offset caused by the rows above.
340   */
341  function realignSpacebarRow(row, prevRow, params, keyHeight, heightOffset) {
342    var allKeys = row.children;
343    var stretchWeightBeforeSpace = 0;
344    var stretchBefore = 0;
345    var stretchWeightAfterSpace = 0;
346    var stretchAfter = 0;
347    var spaceIndex = -1;
348
349    for (var i=0; i< allKeys.length; i++) {
350      if (spaceIndex == -1) {
351        if (allKeys[i].classList.contains('space')) {
352          spaceIndex = i;
353          continue;
354        } else {
355          stretchWeightBeforeSpace += allKeys[i].weight;
356          stretchBefore++;
357        }
358      } else {
359        stretchWeightAfterSpace += allKeys[i].weight;
360        stretchAfter++;
361      }
362    }
363    if (spaceIndex == -1) {
364      console.error("No spacebar found in this row.");
365      return;
366    }
367    var totalWeight = stretchWeightBeforeSpace +
368                      stretchWeightAfterSpace +
369                      allKeys[spaceIndex].weight;
370    var widthForKeys = params.availableWidth -
371                       (params.pitchX * (allKeys.length - 1 ))
372    // Number of pixels to assign per unit weight.
373    var pixelsPerWeight = widthForKeys/totalWeight;
374    // Predicted left edge of the space bar.
375    var spacePredictedLeft = params.offsetLeft +
376                          (spaceIndex * params.pitchX) +
377                          (stretchWeightBeforeSpace * pixelsPerWeight);
378    var prevRowKeys = prevRow.children;
379    // Find closest keys to the spacebar in order to align it to them.
380    var leftKey =
381        findClosestKey(prevRowKeys, spacePredictedLeft, params.pitchX, true);
382
383    var spacePredictedRight = spacePredictedLeft +
384        allKeys[spaceIndex].weight * (params.keyWidth/100);
385
386    var rightKey =
387        findClosestKey(prevRowKeys, spacePredictedRight, params.pitchX, false);
388
389    var yOffset = params.offsetTop + heightOffset;
390    // Fix left side.
391    var leftEdge = parseFloat(leftKey.style.left);
392    var leftWidth = leftEdge - params.offsetLeft - params.pitchX;
393    var leftKeys = allKeys.array().slice(0, spaceIndex);
394    redistribute(leftKeys,
395                 params,
396                 params.offsetLeft,
397                 leftWidth,
398                 keyHeight,
399                 yOffset);
400    // Fix right side.
401    var rightEdge = parseFloat(rightKey.style.left) +
402        parseFloat(rightKey.style.width);
403    var spacebarWidth = rightEdge - leftEdge;
404    updateKey(allKeys[spaceIndex],
405              spacebarWidth,
406              keyHeight,
407              leftEdge,
408              yOffset);
409    var rightWidth =
410        params.availableWidth - (rightEdge - params.offsetLeft + params.pitchX);
411    var rightKeys = allKeys.array().slice(spaceIndex + 1);
412    redistribute(rightKeys,
413                 params,
414                 rightEdge + params.pitchX,//xOffset.
415                 rightWidth,
416                 keyHeight,
417                 yOffset);
418  }
419
420  /**
421   * Realigns a given row based on the parameters provided.
422   * @param {!kb-row} row The row to realign.
423   * @param {!AlignmentOptions} params The parameters used to align the keyset.
424   * @param {number} keyHeight The height of the keys.
425   * @param {number} heightOffset The offset caused by rows above it.
426   */
427  function realignRow(row, params, keyHeight, heightOffset) {
428    var all = row.children;
429    var nStretch = 0;
430    var stretchWeightSum = 0;
431    var allSum = 0;
432    // Keeps track of where to distribute pixels caused by round off errors.
433    var deltaWidth = [];
434    for (var i = 0; i < all.length; i++) {
435      deltaWidth.push(0)
436      var key = all[i];
437      if (key.weight == DEFAULT_KEY_WEIGHT){
438        allSum += params.keyWidth;
439      } else {
440        var width =
441          Math.floor((params.keyWidth/DEFAULT_KEY_WEIGHT) * key.weight);
442        allSum += width;
443      }
444      if (!key.stretch)
445        continue;
446      nStretch++;
447      stretchWeightSum += key.weight;
448    }
449    var nRegular = all.length - nStretch;
450    // Extra space.
451    var extra = params.availableWidth -
452                allSum -
453                (params.pitchX * (all.length -1));
454    var xOffset = params.offsetLeft;
455
456    var alignment = row.align;
457    switch (alignment) {
458      case RowAlignment.STRETCH:
459        var extraPerWeight = extra/stretchWeightSum;
460        for (var i = 0; i < all.length; i++) {
461          if (!all[i].stretch)
462            continue;
463          var delta = Math.floor(all[i].weight * extraPerWeight);
464          extra -= delta;
465          deltaWidth[i] = delta;
466          // All left-over pixels assigned to right most stretchable key.
467          nStretch--;
468          if (nStretch == 0)
469            deltaWidth[i] += extra;
470        }
471        break;
472      case RowAlignment.CENTER:
473        xOffset += Math.floor(extra/2)
474        break;
475      case RowAlignment.RIGHT:
476        xOffset += extra;
477        break;
478      default:
479        break;
480    };
481
482    var yOffset = params.offsetTop + heightOffset;
483    var left = xOffset;
484    for (var i = 0; i < all.length; i++) {
485      var key = all[i];
486      var width = params.keyWidth;
487      if (key.weight != DEFAULT_KEY_WEIGHT)
488        width = Math.floor((params.keyWidth/DEFAULT_KEY_WEIGHT) * key.weight)
489      width += deltaWidth[i];
490      updateKey(key, width, keyHeight, left, yOffset)
491      left += (width + params.pitchX);
492    }
493  }
494
495  /**
496   * Realigns the keysets in all layouts of the keyboard.
497   */
498  function realignAll() {
499    resizeKeyboardContainer()
500    var keyboard = $('keyboard');
501    var layoutParams = {};
502    var idToLayout = function(id) {
503      var parts = id.split('-');
504      parts.pop();
505      return parts.join('-');
506    }
507
508    var keysets = keyboard.querySelectorAll('kb-keyset').array();
509    for (var i=0; i< keysets.length; i++) {
510      var keyset = keysets[i];
511      var layout = idToLayout(keyset.id);
512      // Caches the layouts size parameters since all keysets in the same layout
513      // will have the same specs.
514      if (!(layout in layoutParams))
515        layoutParams[layout] = new AlignmentOptions(keyset);
516      realignKeyset(keyset, layoutParams[layout]);
517    }
518    exports.recordKeysets();
519  }
520
521  /**
522   * Realigns the keysets in the current layout of the keyboard.
523   */
524  function realign() {
525    var keyboard = $('keyboard');
526    var params = new AlignmentOptions();
527    // Check if current window bounds are accurate.
528    resizeKeyboardContainer(params)
529    var layout = keyboard.layout;
530    var keysets =
531        keyboard.querySelectorAll('kb-keyset[id^=' + layout + ']').array();
532    for (var i = 0; i<keysets.length ; i++) {
533      realignKeyset(keysets[i], params);
534    }
535    keyboard.stale = false;
536    exports.recordKeysets();
537  }
538
539  /**
540   * Realigns a given keyset.
541   * @param {Object} keyset The keyset to realign.
542   * @param {!AlignmentOptions} params The parameters used to align the keyset.
543   */
544  function realignKeyset(keyset, params) {
545    var rows = keyset.querySelectorAll('kb-row').array();
546    keyset.style.fontSize = (params.availableHeight /
547      FONT_SIZE_RATIO / rows.length) + 'px';
548    var heightOffset = 0;
549    for (var i = 0; i < rows.length; i++) {
550      var row = rows[i];
551      var rowHeight =
552          Math.floor(params.keyHeight * (row.weight / DEFAULT_KEY_WEIGHT));
553      if (row.querySelector('.space') && (i > 1)) {
554        realignSpacebarRow(row, rows[i-1], params, rowHeight, heightOffset)
555      } else {
556        realignRow(row, params, rowHeight, heightOffset);
557      }
558      heightOffset += (rowHeight + params.pitchY);
559    }
560  }
561
562  /**
563   * Resizes the keyboard container if needed.
564   * @params {AlignmentOptions=} opt_params Optional parameters to use. Defaults
565   *   to the parameters of the current active keyset.
566   */
567  function resizeKeyboardContainer(opt_params) {
568    var params = opt_params ? opt_params : new AlignmentOptions();
569    if (Math.abs(window.innerHeight - params.height) > RESIZE_THRESHOLD) {
570      // Cannot resize more than 50% of screen height due to crbug.com/338829.
571      window.resizeTo(params.width, params.height);
572    }
573  }
574
575  addEventListener('resize', onResize);
576  addEventListener('load', onResize);
577
578  exports.getKeyboardBounds = getKeyboardBounds_;
579  exports.binarySearch = binarySearch_;
580  exports.realignAll = realignAll;
581})(this);
582
583/**
584 * Recursively replace all kb-key-import elements with imported documents.
585 * @param {!Document} content Document to process.
586 */
587function importHTML(content) {
588  var dom = content.querySelector('template').createInstance();
589  var keyImports = dom.querySelectorAll('kb-key-import');
590  if (keyImports.length != 0) {
591    keyImports.array().forEach(function(element) {
592      if (element.importDoc(content)) {
593        var generatedDom = importHTML(element.importDoc(content));
594        element.parentNode.replaceChild(generatedDom, element);
595      }
596    });
597  }
598  return dom;
599}
600
601/**
602  * Flatten the keysets which represents a keyboard layout.
603  */
604function flattenKeysets() {
605  var keysets = $('keyboard').querySelectorAll('kb-keyset');
606  if (keysets.length > 0) {
607    keysets.array().forEach(function(element) {
608      element.flattenKeyset();
609    });
610  }
611}
612
613function resolveAudio() {
614  var keyboard = $('keyboard');
615  keyboard.addSound(Sound.DEFAULT);
616  var nodes = keyboard.querySelectorAll('[sound]').array();
617  // Get id's of all unique sounds.
618  for (var i = 0; i < nodes.length; i++) {
619    var id = nodes[i].getAttribute('sound');
620    keyboard.addSound(id);
621  }
622}
623
624// Prevents all default actions of touch. Keyboard should use its own gesture
625// recognizer.
626addEventListener('touchstart', function(e) { e.preventDefault() });
627addEventListener('touchend', function(e) { e.preventDefault() });
628addEventListener('touchmove', function(e) { e.preventDefault() });
629addEventListener('polymer-ready', function(e) {
630  flattenKeysets();
631  resolveAudio();
632});
633addEventListener('stateChange', function(e) {
634  if (e.detail.value == $('keyboard').activeKeysetId)
635    realignAll();
636})
637