1 /*
  2  *  Copyright © 2009 Apple Inc. All rights reserved.
  3  */
  4 
  5 /* ==================== TKSpatialNavigationManager ==================== */
  6 
  7 /**
  8  *  Indicates whether the spatial navigation manager is enabled, which currently is only the case on Apple TV, or within Safari for development purposes.
  9  *  @constant
 10  *  @type bool
 11  */
 12 const TKSpatialNavigationManagerEnabled = (window.iTunes === undefined || window.iTunes.platform == 'AppleTV') ? true : (parseInt(window.iTunes.version, 0) < 9);
 13 
 14 /**
 15  *  The CSS class name applied to an element when it is highlighted by the spatial navigation manager.
 16  *  @constant
 17  *  @type String
 18  */
 19 const TKSpatialNavigationManagerHighlightCSSClass = 'tk-highlighted';
 20 /**
 21  *  The CSS class name an element should have in order to be ignored by the spatial navigation manager.
 22  *  @constant
 23  *  @type String
 24  */
 25 const TKSpatialNavigationManagerInactiveCSSClass = 'tk-inactive';
 26 
 27 /**
 28  *  The up direction.
 29  *  @constant
 30  *  @type int
 31  */
 32 const TKSpatialNavigationManagerDirectionUp = KEYBOARD_UP;
 33 /**
 34  *  The right direction.
 35  *  @constant
 36  *  @type int
 37  */
 38 const TKSpatialNavigationManagerDirectionRight = KEYBOARD_RIGHT;
 39 /**
 40  *  The down direction.
 41  *  @constant
 42  *  @type int
 43  */
 44 const TKSpatialNavigationManagerDirectionDown = KEYBOARD_DOWN;
 45 /**
 46  *  The left direction.
 47  *  @constant
 48  *  @type int
 49  */
 50 const TKSpatialNavigationManagerDirectionLeft = KEYBOARD_LEFT;
 51 /**
 52  *  The list of keys the spatial navigation manager knows how to handle.
 53  *  @constant
 54  *  @type int
 55  *  @private
 56  */
 57 const TKSpatialNavigationManagerKnownKeys = [KEYBOARD_UP, KEYBOARD_RIGHT, KEYBOARD_DOWN, KEYBOARD_LEFT, KEYBOARD_BACKSPACE, KEYBOARD_RETURN];
 58 
 59 /**
 60  *  The number of controllers that are currently busy, when for instance performing a transition that should not be interrupted. When this variable is more than
 61  *  <code>0</code>, key handling by the spatial navigation manager is suspended.
 62  *  @type int
 63  */
 64 TKSpatialNavigationManager.busyControllers = 0;
 65 /**
 66  *  The identifier for the sound to play for the current event loop.
 67  *  @type int
 68  */
 69 TKSpatialNavigationManager.soundToPlay = null;
 70 
 71 /* ==================== Creating the shared instance lazily ==================== */
 72 
 73 /**
 74  *  @name TKSpatialNavigationManager
 75  *  @property {TKSpatialNavigationManager} sharedManager The shared instance of the spatial navigation manager. TuneKit automatically creates a single instance
 76  *  of the {@link TKSpatialNavigationManager} class as needed, and developers should never have to create an instance themselves, instead using this property
 77  *  to retrieve the shared instance.
 78  */
 79 TKSpatialNavigationManager._sharedManager = null;
 80 TKSpatialNavigationManager.__defineGetter__('sharedManager', function () {
 81   if (this._sharedManager === null) {
 82     this._sharedManager = new TKSpatialNavigationManager();
 83   }
 84   return this._sharedManager;
 85 });
 86 
 87 /* ==================== Constructor ==================== */
 88 
 89 TKSpatialNavigationManager.inherits = TKObject;
 90 TKSpatialNavigationManager.includes = [TKEventTriage];
 91 TKSpatialNavigationManager.synthetizes = ['managedController'];
 92 
 93 /**
 94  *  @class
 95  *
 96  *  <p>The spatial navigation manager is a special controller type that sits behind the scenes and handles much of the keyboard interaction in order
 97  *  to provide navigation between navigable elements of the {@link #managedController}. By default, navigation between navigable elements is automatic and
 98  *  performed based on the location and metrics of each elements. The elements' metrics are those set by CSS and a controller is free to provide custom
 99  *  metrics for elements as it sees fit by implementing the {@link TKController#customMetricsForElement} method. Additionally, the automatic navigation
100  *  can be completely bypassed should the managed controller provide a custom element to navigate to with the
101  *  {@link TKController#preferredElementToHighlightInDirection} method.</p>
102  *
103  *  @extends TKObject
104  *  @since TuneKit 1.0
105  */
106 function TKSpatialNavigationManager () {
107   this.callSuper();
108   //
109   this._managedController = null;
110   /**
111    *  The complete list of all elements that can be navigated to within this controller and all of its sub-controllers.
112    *  @type Array
113    */
114   this.navigableElements = [];
115   this.highlightedElement = null;
116   this.previousNavigation = null;
117   // register for keyboard events if we're running outside of the iTunes app
118   if (TKSpatialNavigationManagerEnabled) {
119     window.addEventListener('keydown', this, true);
120   }
121 };
122 
123 /**
124  *  @name TKSpatialNavigationManager.prototype
125  *  @property managedController The managed controller is the controller that the spatial navigation manager queries for navigable elements
126  *  and any customization of the otherwise automated navigation. Developers should not assign this property directly as the navigation controller takes care of
127  *  that as the user navigates through controllers.
128  *  @type TKController
129  */
130 TKSpatialNavigationManager.prototype.setManagedController = function (controller) {
131   this._managedController = controller;
132   this.navigableElements = controller.navigableElements;
133   this.previousNavigation = null;
134   // is this the first time we're managing this controller?
135   if (controller._wasAlreadyManagedBySpatialNavigationManager === undefined) {
136     // see if it had an archived highlighted element
137     var archived_index = controller.getArchivedProperty('highlightedElementIndex');
138     if (archived_index !== undefined) {
139       var archived_element = controller.navigableElements[archived_index];
140       if (archived_element instanceof Element) {
141         controller.highlightedElement = archived_element;
142       }
143     }
144     // track that we've managed it before
145     controller._wasAlreadyManagedBySpatialNavigationManager = true;
146   }
147   // reset the highlighted element to be nothing
148   this.highlightedElement = null;
149   // if we have a preferred or recorded highlighted element, highlight that
150   if (controller.highlightedElement !== null) {
151     this.highlightElement(controller.highlightedElement);
152   }
153   // otherwise default to the top-most element
154   else {
155     this.highlightTopElement();
156   }
157 };
158 
159 TKSpatialNavigationManager.prototype.registerController = function (controller) {
160   var elements = controller.navigableElements;
161   for (var i = 0; i < elements.length; i++) {
162     this.addNavigableElement(elements[i]);
163   }
164 };
165 
166 TKSpatialNavigationManager.prototype.unregisterController = function (controller) {
167   var elements = controller.navigableElements;
168   for (var i = 0; i < elements.length; i++) {
169     this.removeNavigableElement(elements[i]);
170   }
171 };
172 
173 TKSpatialNavigationManager.prototype.addNavigableElement = function (element) {
174   // nothing to do if the new element is not rooted in the managed hierarchy or we already know it
175   if (!element._controller.isDescendentOfController(this._managedController) ||
176       this.navigableElements.indexOf(element) > -1) {
177     return;
178   }
179   // and keep track of it
180   this.navigableElements.push(element);
181 };
182 
183 TKSpatialNavigationManager.prototype.removeNavigableElement = function (element) {
184   // find the index for this element
185   var index = this.navigableElements.indexOf(element);
186   if (index < 0) {
187     return;
188   }
189   // remove elements from the tracking arrays
190   this.navigableElements.splice(index, 1);
191 };
192 
193 /* ==================== Keyboard Navigation ==================== */
194 
195 TKSpatialNavigationManager.prototype.handleKeydown = function (event) {
196 
197   var key = event.keyCode;
198 
199   // check if our controller knows what it's doing and let it take over in case it does
200   if (this._managedController.wantsToHandleKey(key)) {
201     // prevent default actions
202     event.stopPropagation();
203     event.preventDefault();
204     // have the controller do what it think is best in this case
205     this._managedController.keyWasPressed(key);
206     return;
207   }
208 
209   // reset the sound
210   TKSpatialNavigationManager.soundToPlay = null;
211 
212   // check we know about this key, otherwise, do nothing
213   if (TKSpatialNavigationManagerKnownKeys.indexOf(key) == -1) {
214     return;
215   }
216 
217   var navigation = TKNavigationController.sharedNavigation;
218   // first, check if we're hitting the back button on the home screen, in which case
219   // we don't want to do anything and let the User Agent do what's right to exit
220   if (event.keyCode == KEYBOARD_BACKSPACE && navigation.topController === homeController) {
221     return;
222   }
223   
224   // before we go any further, prevent the default action from happening
225   event.stopPropagation();
226   event.preventDefault();
227   
228   // check if we're busy doing other things
229   if (TKSpatialNavigationManager.busyControllers > 0) {
230     return;
231   }
232   // see if we pressed esc. so we can pop to previous controller
233   if (event.keyCode == KEYBOARD_BACKSPACE) {
234     var top_controller = navigation.topController;
235     if (top_controller !== homeController) {
236       // at any rate, play the exit sound
237       TKUtils.playSound(SOUND_EXIT);
238       // see if the top controller has a custom place to navigate to with the back button
239       if (top_controller.backButton instanceof Element && top_controller.backButton._navigationData !== undefined) {
240         navigation.pushController(TKController.resolveController(top_controller.backButton._navigationData.controller));
241       }
242       // otherwise, just pop the controller
243       else {
244         navigation.popController();
245       }
246     }
247   }
248   // check if we want to activate an element
249   else if (key == KEYBOARD_RETURN) {
250     if (this.highlightedElement !== null) {
251       var success = this.highlightedElement._controller.elementWasActivated(this.highlightedElement);
252       TKUtils.playSound(TKSpatialNavigationManager.soundToPlay === null ? SOUND_ACTIVATED : TKSpatialNavigationManager.soundToPlay);
253     }
254     else {
255       TKUtils.playSound(SOUND_LIMIT);
256     }
257   }
258   // keyboard nav
259   else {
260     var key_index = TKSpatialNavigationManagerKnownKeys.indexOf(key);
261     // do nothing if we don't have any highlightable elements or don't know about this navigation direction
262     if (this.navigableElements.length == 0 || key_index == -1) {
263       TKUtils.playSound(SOUND_LIMIT);
264       return;
265     }
266     // figure the index of the element to highlight
267     var index = this.nearestElementIndexInDirection(key);
268 
269     // get a pointer to the controller of the previous item if we have one
270     if (this.highlightedElement !== null) {
271       var previous_controller = this.highlightedElement._controller;
272 
273       // see if we're being provided custom navigation by the controller instance
274       var provided_preferred_element = false;
275       var preferred_highlighted_element = previous_controller.preferredElementToHighlightInDirection(this.highlightedElement, key);
276       // try again with the private method meant to be implemented by the sub-class
277       if (preferred_highlighted_element === undefined) {
278         preferred_highlighted_element = previous_controller._preferredElementToHighlightInDirection(this.highlightedElement, key);
279       }
280       // we explicitly do not want to highlight anything
281       if (preferred_highlighted_element === null) {
282         index = -1;
283       }
284       else if (preferred_highlighted_element !== undefined) {
285         var preferred_highlight_index = this.navigableElements.indexOf(preferred_highlighted_element);
286         // if this element is in our navigation list and is ready to be navigated to
287         if (preferred_highlight_index >= 0 && this.isElementAtIndexNavigable(preferred_highlight_index)) {
288           index = preferred_highlight_index;
289           provided_preferred_element = true;
290         }
291       }
292 
293       // stop right there if we have no useful index
294       if (index == -1) {
295         TKUtils.playSound(SOUND_LIMIT);
296         return;
297       }
298     }
299 
300     // get a pointer to the controller of the item we consider highlighting now
301     var next_controller = this.navigableElements[index]._controller;
302 
303     // we're moving out of a tab controller into one controller managed by that tab controller
304     // in which case we want to highlight the first item in that controller based on its orientation
305     if (previous_controller instanceof TKTabController &&
306         this.highlightedElement._tabIndex !== undefined &&
307         next_controller.parentController === previous_controller) {
308       index = this.navigableElements.indexOf(next_controller.highlightedElement || next_controller.navigableElements[0]);
309     }
310     // we're moving back to a tab element from an element managed by a controller
311     // that is itself managed by the very tab controller we're focusing, so let's highlight
312     // the element that is selected in that tab controller
313     else if (next_controller instanceof TKTabController &&
314              this.navigableElements[index]._tabIndex !== undefined &&
315              previous_controller.parentController === next_controller) {
316       index = this.navigableElements.indexOf(next_controller.tabs[next_controller.selectedIndex]);
317     }
318     // check if we were doing the reverse operation to the last one
319     else if (!provided_preferred_element && this.previousNavigation !== null && key_index == (this.previousNavigation.keyIndex + 2) % 4) {
320       var previous_element_index = this.navigableElements.indexOf(this.previousNavigation.element);
321       if (previous_element_index > -1 && this.isElementAtIndexNavigable(previous_element_index)) {
322         index = previous_element_index;
323       }
324     }
325     
326     // get a pointer to the next element to highlight
327     var next_highlighted_element = (index >= 0 && index < this.navigableElements.length) ? this.navigableElements[index] : null;
328     
329     // only highlight if we know what element to highlight
330     if (next_highlighted_element !== null && next_highlighted_element.isNavigable()) {
331       // track the interaction so we can go back to it
332       if (this.highlightedElement !== null) {
333         this.previousNavigation = {
334           element: this.highlightedElement,
335           keyIndex : key_index
336         };
337       }
338       this.highlightElement(next_highlighted_element);
339       TKUtils.playSound(SOUND_MOVED);
340     }
341     else {
342       TKUtils.playSound(SOUND_LIMIT);
343     }
344   }
345 };
346 
347 TKSpatialNavigationManager.prototype.nearestElementIndexInDirection = function (direction) {
348   // nothing to do if we don't have a next element
349   if (this.highlightedElement === null) {
350     if (direction == TKSpatialNavigationManagerDirectionUp) {
351       return this.bottomMostIndex();
352     }
353     else if (direction == TKSpatialNavigationManagerDirectionRight) {
354       return this.leftMostIndex();
355     }
356     else if (direction == TKSpatialNavigationManagerDirectionDown) {
357       return this.topMostIndex();
358     }
359     else if (direction == TKSpatialNavigationManagerDirectionLeft) {
360       return this.rightMostIndex();
361     }    
362   }
363   // figure out parameters
364   var ref_position, target_edge;
365   if (direction == TKSpatialNavigationManagerDirectionUp) {
366     ref_position = TKRectMiddleOfTopEdge;
367     target_edge = TKRectBottomEdge;
368   }
369   else if (direction == TKSpatialNavigationManagerDirectionRight) {
370     ref_position = TKRectMiddleOfRightEdge;
371     target_edge = TKRectLeftEdge;
372   }
373   else if (direction == TKSpatialNavigationManagerDirectionDown) {
374     ref_position = TKRectMiddleOfBottomEdge;
375     target_edge = TKRectTopEdge;
376   }
377   else if (direction == TKSpatialNavigationManagerDirectionLeft) {
378     ref_position = TKRectMiddleOfLeftEdge;
379     target_edge = TKRectRightEdge;
380   }
381   // look for the closest element now
382   var index = -1;
383   var min_d = 10000000;
384   var highlight_index = this.navigableElements.indexOf(this.highlightedElement);
385   var ref_metrics = this.metricsForElement(this.highlightedElement);
386   var ref_point = ref_metrics.pointAtPosition(ref_position);
387   var ref_center = ref_metrics.pointAtPosition(TKRectCenter);
388   for (var i = 0; i < this.navigableElements.length; i++) {
389     // see if we should skip this element
390     if (!this.isElementAtIndexNavigable(i)) {
391       continue;
392     }
393     var metrics = this.metricsForElement(this.navigableElements[i]);
394     // go to next item if it's not in the right direction or already has highlight
395     if ((direction == TKSpatialNavigationManagerDirectionUp && metrics.pointAtPosition(TKRectBottomLeftCorner).y > ref_center.y) ||
396         (direction == TKSpatialNavigationManagerDirectionRight && metrics.pointAtPosition(TKRectTopLeftCorner).x < ref_center.x) ||
397         (direction == TKSpatialNavigationManagerDirectionDown && metrics.pointAtPosition(TKRectTopLeftCorner).y < ref_center.y) ||
398         (direction == TKSpatialNavigationManagerDirectionLeft && metrics.pointAtPosition(TKRectTopRightCorner).x > ref_center.x) ||
399         i == highlight_index) {
400       continue;
401     }
402     var d = metrics.edge(target_edge).distanceToPoint(ref_point);
403     if (d < min_d) {
404       min_d = d;
405       index = i;
406     }
407   }
408   // return the index, if any
409   return index;
410 };
411 
412 TKSpatialNavigationManager.prototype.topMostIndex = function () {
413   var index = 0;
414   var min_y = 10000;
415   for (var i = 0; i < this.navigableElements.length; i++) {
416     if (!this.isElementAtIndexNavigable(i)) {
417       continue;
418     }
419     var y = this.metricsForElementAtIndex(i).y;
420     if (y < min_y) {
421       min_y = y;
422       index = i;
423     }
424   }
425   return index;
426 };
427 
428 TKSpatialNavigationManager.prototype.rightMostIndex = function () {
429   var index = 0;
430   var max_x = 0;
431   for (var i = 0; i < this.navigableElements.length; i++) {
432     if (!this.isElementAtIndexNavigable(i)) {
433       continue;
434     }
435     var x = this.metricsForElementAtIndex(i).pointAtPosition(TKRectTopRightCorner).x;
436     if (x > max_x) {
437       max_x = x;
438       index = i;
439     }
440   }
441   return index;
442 };
443 
444 TKSpatialNavigationManager.prototype.bottomMostIndex = function () {
445   var index = 0;
446   var max_y = 0;
447   for (var i = 0; i < this.navigableElements.length; i++) {
448     if (!this.isElementAtIndexNavigable(i)) {
449       continue;
450     }
451     var y = this.metricsForElementAtIndex(i).pointAtPosition(TKRectBottomRightCorner).y;
452     if (y > max_y) {
453       max_y = y;
454       index = i;
455     }
456   }
457   return index;
458 };
459 
460 TKSpatialNavigationManager.prototype.leftMostIndex = function () {
461   var index = 0;
462   var min_x = 10000;
463   for (var i = 0; i < this.navigableElements.length; i++) {
464     if (!this.isElementAtIndexNavigable(i)) {
465       continue;
466     }
467     var y = this.metricsForElementAtIndex(i).x;
468     if (y < min_x) {
469       min_x = y;
470       index = i;
471     }
472   }
473   return index;
474 };
475 
476 TKSpatialNavigationManager.prototype.metricsForElement = function (element) {
477   return element._controller.customMetricsForElement(element) || element.getBounds();
478 };
479 
480 TKSpatialNavigationManager.prototype.metricsForElementAtIndex = function (index) {
481   return this.metricsForElement(this.navigableElements[index]);
482 };
483 
484 /**
485  *  Highlight the top-most element in the list of navigable elements.
486  */
487 TKSpatialNavigationManager.prototype.highlightTopElement = function () {
488   // now see if we need to enforce some default element
489   if (this.navigableElements.length > 0) {
490     this.highlightElement(this.navigableElements[this.topMostIndex()]);
491   }
492 };
493 
494 /**
495  *  Indicates whether a given element is navigable at the provided index in the {@link #navigableElements} array.
496  *
497  *  @param {Element} element The index for the element in the {@link #navigableElements} array.
498  *  @returns {bool} Whether the element can be navigated to.
499  */
500 TKSpatialNavigationManager.prototype.isElementAtIndexNavigable = function (index) {
501   return this.navigableElements[index].isNavigable();
502 };
503 
504 /* ==================== Highlight Management ==================== */
505 
506 /**
507  *  Highlights a given element if it's part of the {@link #navigableElements} array. When an element receives highlight, a <code>highlight</code> event is 
508  *  dispatched to that element, while an <code>unhighlight</code> event is dispatched to the element that previously had highlight.
509  *
510  *  @param {Element} element The element to highlight.
511  */
512 TKSpatialNavigationManager.prototype.highlightElement = function (element) {
513   // nothing to do if we don't really have an element to highlight
514   if (!(element instanceof Element)) {
515     return;
516   }
517   // check that this element is navigable, and do nothing if it's not
518   var navigation_index = this.navigableElements.indexOf(element);
519   if (navigation_index == -1 || !this.isElementAtIndexNavigable(navigation_index)) {
520     return;
521   }
522   //
523   if (this.highlightedElement !== null) {
524     this.highlightedElement.dispatchEvent(TKUtils.createEvent('unhighlight', element));
525     if (TKSpatialNavigationManagerEnabled) {
526       this.highlightedElement.removeClassName(TKSpatialNavigationManagerHighlightCSSClass);
527     }
528   }
529   //
530   element.dispatchEvent(TKUtils.createEvent('highlight', this.highlightedElement));
531   if (TKSpatialNavigationManagerEnabled) {
532     element.addClassName(TKSpatialNavigationManagerHighlightCSSClass);
533   }
534   this.highlightedElement = element;
535   // track on its controller that it was the last with highlight
536   element._controller.highlightedElement = element;
537 };
538 
539 TKClass(TKSpatialNavigationManager);
540