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