1 // ========================================================================== 2 // Project: SproutCore - JavaScript Application Framework 3 // Copyright: ©2006-2011 Strobe Inc. and contributors. 4 // Portions ©2008-2011 Apple Inc. All rights reserved. 5 // License: Licensed under MIT license (see license.js) 6 // ========================================================================== 7 8 /** 9 @class 10 11 SC.routes manages the browser location. You can change the hash part of the 12 current location. The following code 13 14 SC.routes.set('location', 'notes/edit/4'); 15 16 will change the location to http://domain.tld/my_app#notes/edit/4. Adding 17 routes will register a handler that will be called whenever the location 18 changes and matches the route: 19 20 SC.routes.add(':controller/:action/:id', MyApp, MyApp.route); 21 22 You can pass additional parameters in the location hash that will be relayed 23 to the route handler: 24 25 SC.routes.set('location', 'notes/show/4?format=xml&language=fr'); 26 27 The syntax for the location hash is described in the location property 28 documentation, and the syntax for adding handlers is described in the 29 add method documentation. 30 31 Browsers keep track of the locations in their history, so when the user 32 presses the 'back' or 'forward' button, the location is changed, SC.route 33 catches it and calls your handler. Except for Internet Explorer versions 7 34 and earlier, which do not modify the history stack when the location hash 35 changes. 36 37 SC.routes also supports HTML5 history, which uses a '/' instead of a '#' 38 in the URLs, so that all your website's URLs are consistent. 39 */ 40 SC.routes = SC.Object.create( 41 /** @scope SC.routes.prototype */{ 42 43 /** 44 Set this property to YES if you want to use HTML5 history, if available on 45 the browser, instead of the location hash. 46 47 HTML 5 history uses the history.pushState method and the window's popstate 48 event. 49 50 By default it is NO, so your URLs will look like: 51 52 http://domain.tld/my_app#notes/edit/4 53 54 If set to YES and the browser supports pushState(), your URLs will look 55 like: 56 57 http://domain.tld/my_app/notes/edit/4 58 59 You will also need to make sure that baseURI is properly configured, as 60 well as your server so that your routes are properly pointing to your 61 SproutCore application. 62 63 @see http://dev.w3.org/html5/spec/history.html#the-history-interface 64 @property 65 @type {Boolean} 66 */ 67 wantsHistory: NO, 68 69 /** 70 A read-only boolean indicating whether or not HTML5 history is used. Based 71 on the value of wantsHistory and the browser's support for pushState. 72 73 @see wantsHistory 74 @property 75 @type {Boolean} 76 */ 77 usesHistory: null, 78 79 /** 80 The base URI used to resolve routes (which are relative URLs). Only used 81 when usesHistory is equal to YES. 82 83 The build tools automatically configure this value if you have the 84 html5_history option activated in the Buildfile: 85 86 config :my_app, :html5_history => true 87 88 Alternatively, it uses by default the value of the href attribute of the 89 <base> tag of the HTML document. For example: 90 91 <base href="http://domain.tld/my_app"> 92 93 The value can also be customized before or during the execution of the 94 main() method. 95 96 @see http://www.w3.org/TR/html5/semantics.html#the-base-element 97 @property 98 @type {String} 99 */ 100 baseURI: document.baseURI, 101 102 /** @private 103 A boolean value indicating whether or not the ping method has been called 104 to setup the SC.routes. 105 106 @property 107 @type {Boolean} 108 */ 109 _didSetup: NO, 110 111 /** @private 112 Internal representation of the current location hash. 113 114 @property 115 @type {String} 116 */ 117 _location: null, 118 119 /** @private 120 Routes are stored in a tree structure, this is the root node. 121 122 @property 123 @type {SC.routes._Route} 124 */ 125 _firstRoute: null, 126 127 /** @private 128 Internal method used to extract and merge the parameters of a URL. 129 130 @returns {Hash} 131 */ 132 _extractParametersAndRoute: function(obj, coerce) { 133 var params = {}, 134 route = obj.route || '', 135 separator, parts, i, len, crumbs, key; 136 137 separator = (route.indexOf('?') < 0 && route.indexOf('&') >= 0) ? '&' : '?'; 138 139 parts = route.split(separator); 140 route = parts.shift(); 141 142 params = this.deparam(parts.join('&'), coerce || false); 143 144 // overlay any parameter passed in obj 145 for (key in obj) { 146 if (obj.hasOwnProperty(key) && key !== 'route') { 147 params[key] = '' + obj[key]; 148 } 149 } 150 151 params.params = separator + $.param(params); 152 params.route = route; 153 154 return params; 155 }, 156 157 /** 158 The current location hash. It is the part in the browser's location after 159 the '#' mark. 160 161 The following code 162 163 SC.routes.set('location', 'notes/edit/4'); 164 165 will change the location to http://domain.tld/my_app#notes/edit/4 and call 166 the correct route handler if it has been registered with the add method. 167 168 You can also pass additional parameters. They will be relayed to the route 169 handler. For example, the following code 170 171 SC.routes.add(':controller/:action/:id', MyApp, MyApp.route); 172 SC.routes.set('location', 'notes/show/4?format=xml&language=fr'); 173 174 will change the location to 175 http://domain.tld/my_app#notes/show/4?format=xml&language=fr and call the 176 MyApp.route method with the following argument: 177 178 { route: 'notes/show/4', 179 params: '?format=xml&language=fr', 180 controller: 'notes', 181 action: 'show', 182 id: '4', 183 format: 'xml', 184 language: 'fr' } 185 186 The location can also be set with a hash, the following code 187 188 SC.routes.set('location', 189 { route: 'notes/edit/4', format: 'xml', language: 'fr' }); 190 191 will change the location to 192 http://domain.tld/my_app#notes/show/4?format=xml&language=fr. 193 194 The 'notes/show/4&format=xml&language=fr' syntax for passing parameters, 195 using a '&' instead of a '?', as used in SproutCore 1.0 is still supported. 196 197 @property 198 @type {String} 199 */ 200 location: function(key, value) { 201 var lsk; 202 this._skipRoute = NO; 203 if (value !== undefined) { 204 // The 'location' and 'informLocation' properties essentially 205 // represent a single property, but with different behavior 206 // when setting the value. Because of this, we manually 207 // update the cached value for the opposite property to 208 // ensure they remain in sync. You shouldn't do this in 209 // your own code unless you REALLY know what you are doing. 210 lsk = this.informLocation.lastSetValueKey; 211 if (lsk && this._kvo_cache) this._kvo_cache[lsk] = value; 212 } 213 return this._extractLocation(key, value); 214 }.property(), 215 216 /* 217 Works exactly like 'location' but you use this property only when 218 you want to just change the location w/out triggering the routes 219 */ 220 informLocation: function(key, value){ 221 var lsk; 222 this._skipRoute = YES; 223 if (value !== undefined) { 224 // The 'location' and 'informLocation' properties essentially 225 // represent a single property, but with different behavior 226 // when setting the value. Because of this, we manually 227 // update the cached value for the opposite property to 228 // ensure they remain in sync. You shouldn't do this in 229 // your own code unless you REALLY know what you are doing. 230 lsk = this.location.lastSetValueKey; 231 if (lsk && this._kvo_cache) this._kvo_cache[lsk] = value; 232 } 233 return this._extractLocation(key, value); 234 }.property(), 235 236 _extractLocation: function(key, value) { 237 var crumbs, encodedValue; 238 239 if (value !== undefined) { 240 if (value === null) { 241 value = ''; 242 } 243 244 if (typeof(value) === 'object') { 245 crumbs = this._extractParametersAndRoute(value); 246 value = crumbs.route + crumbs.params; 247 } 248 249 // Only update the browser if this event triggered from within the app, rather 250 // than from the browser back or forward buttons. 251 if (!this._exogenous) { 252 if (!SC.empty(value) || (this._location && this._location !== value)) { 253 encodedValue = encodeURI(value); 254 255 if (this.usesHistory) { 256 if (encodedValue.length > 0) { 257 encodedValue = '/' + encodedValue; 258 } 259 window.history.pushState(null, null, this.get('baseURI') + encodedValue); 260 } else { 261 window.location.hash = encodedValue; 262 } 263 } 264 } 265 266 // Cache locally. 267 this._location = value; 268 } 269 270 return this._location; 271 }, 272 273 /** 274 You usually don't need to call this method. It is done automatically after 275 the application has been initialized. 276 277 It registers for the hashchange event if available. If not, it creates a 278 timer that looks for location changes every 150ms. 279 */ 280 ping: function() { 281 var that; 282 283 if (!this._didSetup) { 284 this._didSetup = YES; 285 286 if (this.get('wantsHistory') && SC.platform.supportsHistory) { 287 this.usesHistory = YES; 288 289 this.popState(); 290 SC.Event.add(window, 'popstate', this, this.popState); 291 292 } else { 293 this.usesHistory = NO; 294 295 if (SC.platform.supportsHashChange) { 296 this.hashChange(); 297 SC.Event.add(window, 'hashchange', this, this.hashChange); 298 299 } else { 300 // we don't use a SC.Timer because we don't want 301 // a run loop to be triggered at each ping 302 that = this; 303 this._invokeHashChange = function() { 304 that.hashChange(); 305 setTimeout(that._invokeHashChange, 100); 306 }; 307 this._invokeHashChange(); 308 } 309 } 310 } 311 }, 312 313 /** 314 Event handler for the hashchange event. Called automatically by the browser 315 if it supports the hashchange event, or by our timer if not. 316 */ 317 hashChange: function(event) { 318 // Mark this location change as coming from the browser, which therefore doesn't 319 // need to be updated. 320 this._exogenous = YES; 321 322 var loc = window.location.hash; 323 324 // Remove the '#' prefix 325 loc = (loc && loc.length > 0) ? loc.slice(1, loc.length) : ''; 326 327 if (!SC.browser.isMozilla) { 328 // because of bug https://bugzilla.mozilla.org/show_bug.cgi?id=483304 329 loc = decodeURI(loc); 330 } 331 332 if (this.get('location') !== loc && !this._skipRoute) { 333 SC.run(function() { 334 this.set('location', loc); 335 }, this); 336 } 337 338 this._skipRoute = NO; 339 this._exogenous = NO; 340 }, 341 342 popState: function(event) { 343 // Mark this location change as coming from the browser, which therefore doesn't 344 // need to be updated. 345 this._exogenous = YES; 346 347 var base = this.get('baseURI'), 348 loc = document.location.href; 349 350 if (loc.slice(0, base.length) === base) { 351 352 // Remove the base prefix and the extra '/' 353 loc = loc.slice(base.length + 1, loc.length); 354 355 if (this.get('location') !== loc && !this._skipRoute) { 356 SC.run(function() { 357 this.set('location', loc); 358 }, this); 359 } 360 } 361 362 this._skipRoute = NO; 363 this._exogenous = NO; 364 }, 365 366 /** 367 Adds a route handler. Routes have the following format: 368 369 - 'users/show/5' is a static route and only matches this exact string, 370 - ':action/:controller/:id' is a dynamic route and the handler will be 371 called with the 'action', 'controller' and 'id' parameters passed in a 372 hash, 373 - '*url' is a wildcard route, it matches the whole route and the handler 374 will be called with the 'url' parameter passed in a hash. 375 376 Route types can be combined, the following are valid routes: 377 378 - 'users/:action/:id' 379 - ':controller/show/:id' 380 - ':controller/*url' 381 382 @param {String} route the route to be registered 383 @param {Object} target the object on which the method will be called, or 384 directly the function to be called to handle the route 385 @param {Function} method the method to be called on target to handle the 386 route, can be a function or a string 387 */ 388 add: function(route, target, method) { 389 if (!this._didSetup) { 390 this.invokeNext(this.ping); 391 } 392 393 if (method === undefined && SC.typeOf(target) === SC.T_FUNCTION) { 394 method = target; 395 target = null; 396 } else if (SC.typeOf(method) === SC.T_STRING) { 397 method = target[method]; 398 } 399 400 if (!this._firstRoute) this._firstRoute = this._Route.create(); 401 this._firstRoute.add(route.split('/'), target, method); 402 403 return this; 404 }, 405 406 /** 407 Observer of the 'location' property that calls the correct route handler 408 when the location changes. 409 */ 410 locationDidChange: function() { 411 this.trigger(); 412 }.observes('location'), 413 414 /** 415 Triggers a route even if already in that route (does change the location, if it 416 is not already changed, as well). 417 418 If the location is not the same as the supplied location, this simply lets "location" 419 handle it (which ends up coming back to here). 420 */ 421 trigger: function() { 422 var firstRoute = this._firstRoute, 423 location = this.get('location'), 424 params, route; 425 426 if (firstRoute) { 427 params = this._extractParametersAndRoute({ route: location }); 428 location = params.route; 429 delete params.route; 430 delete params.params; 431 route = firstRoute.routeForParts(location.split('/'), params); 432 if (route && route.method) { 433 route.method.call(route.target || this, params); 434 } 435 } 436 }, 437 438 /** 439 Function to create an object out of a urlencoded param string. 440 441 @param {String} string the parameter string 442 @param {Boolean} coerce coerce the values? (Default NO) 443 */ 444 deparam: function(string, coerce) { 445 var obj = {}, 446 coerce_types = { 'true': !0, 'false': !1, 'null': null }, 447 params, len, idx, param, key, val, cur, i, keys, keys_last, 448 dec = decodeURIComponent, toString = Object.prototype.toString; 449 450 // This allows any URL-like string to also be objectified 451 if (string.indexOf('?') >= 0) { 452 string = string.split('?')[1]; 453 if (string.indexOf('#') >= 0) { 454 string = string.split('#')[0]; 455 } 456 } else if (string.indexOf('#') >= 0) { 457 string = string.split('#')[1]; 458 } 459 460 params = string.replace(/\+/g, ' ').split('&'); 461 len = params.length; 462 for (idx = 0; idx < len; ++idx) { 463 param = params[idx].split('='), key = dec(param[0]), cur = obj, 464 keys = key.split(']['), keys_last = key.length - 1; 465 466 if (/\[/.test(keys[0]) && /\]$/.test(keys[keys_last])) { 467 keys[keys_last] = keys[keys_last].replace(/\]$/, ''); 468 keys = keys.shift().split('[').concat(keys); 469 keys_last = keys.length - 1; 470 } else { keys_last = 0; } 471 472 if (param.length === 2) { 473 val = dec(param[1]); 474 475 if (coerce) { 476 val = val && !isNaN(val) ? +val // gotta be a number 477 : val === 'undefined' ? undefined 478 : coerce_types[val] !== undefined ? coerce_types[val] 479 : val; 480 } 481 482 if (keys_last) { 483 for (i = 0; i < keys_last; ++i) { 484 key = keys[i] === '' ? cur.length : keys[i]; 485 cur = cur[key] = i < keys_last 486 ? cur[key] || (keys[i + 1] && isNaN(keys[i + 1]) ? {} : []) 487 : val; 488 } 489 } else { 490 if (toString.apply(obj[key]) === '[object Array]') obj[key].push(val); 491 else if (obj[key] !== undefined) obj[key] = [obj[key], val]; 492 else obj[key] = val; 493 } 494 } else if (key) { 495 obj[key] = coerce ? undefined : ''; 496 } 497 } 498 return obj; 499 }, 500 501 /** 502 @private 503 @class 504 505 SC.routes._Route is a class used internally by SC.routes. The routes defined by your 506 application are stored in a tree structure, and this is the class for the 507 nodes. 508 */ 509 _Route: SC.Object.extend( 510 /** @scope SC.routes._Route.prototype */ { 511 512 target: null, 513 514 method: null, 515 516 staticRoutes: null, 517 518 dynamicRoutes: null, 519 520 wildcardRoutes: null, 521 522 add: function(parts, target, method) { 523 var part, nextRoute; 524 525 // clone the parts array because we are going to alter it 526 parts = SC.clone(parts); 527 528 if (!parts || parts.length === 0) { 529 this.target = target; 530 this.method = method; 531 532 } else { 533 part = parts.shift(); 534 535 // there are 3 types of routes 536 switch (part.slice(0, 1)) { 537 538 // 1. dynamic routes 539 case ':': 540 part = part.slice(1, part.length); 541 if (!this.dynamicRoutes) this.dynamicRoutes = {}; 542 if (!this.dynamicRoutes[part]) this.dynamicRoutes[part] = this.constructor.create(); 543 nextRoute = this.dynamicRoutes[part]; 544 break; 545 546 // 2. wildcard routes 547 case '*': 548 part = part.slice(1, part.length); 549 if (!this.wildcardRoutes) this.wildcardRoutes = {}; 550 nextRoute = this.wildcardRoutes[part] = this.constructor.create(); 551 break; 552 553 // 3. static routes 554 default: 555 if (!this.staticRoutes) this.staticRoutes = {}; 556 if (!this.staticRoutes[part]) this.staticRoutes[part] = this.constructor.create(); 557 nextRoute = this.staticRoutes[part]; 558 } 559 560 // recursively add the rest of the route 561 if (nextRoute) nextRoute.add(parts, target, method); 562 } 563 }, 564 565 routeForParts: function(parts, params) { 566 var part, key, route; 567 568 // clone the parts array because we are going to alter it 569 parts = SC.clone(parts); 570 571 // if parts is empty, we are done 572 if (!parts || parts.length === 0) { 573 return this.method ? this : null; 574 575 } else { 576 part = parts.shift(); 577 578 // try to match a static route 579 if (this.staticRoutes && this.staticRoutes[part]) { 580 return this.staticRoutes[part].routeForParts(parts, params); 581 582 } else { 583 584 // else, try to match a dynamic route 585 for (key in this.dynamicRoutes) { 586 route = this.dynamicRoutes[key].routeForParts(parts, params); 587 if (route) { 588 params[key] = part; 589 return route; 590 } 591 } 592 593 // else, try to match a wildcard route 594 for (key in this.wildcardRoutes) { 595 parts.unshift(part); 596 params[key] = parts.join('/'); 597 return this.wildcardRoutes[key].routeForParts(null, params); 598 } 599 600 // if nothing was found, it means that there is no match 601 return null; 602 } 603 } 604 } 605 606 }) 607 608 }); 609