From fcce055e6c189554775dc7b03f2300146e7b0740 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 30 Jun 2015 13:26:08 -0500 Subject: [PATCH] router updates --- ionic/components/app/app.js | 7 +- ionic/routing/path-recognizer.js | 181 +++++++++++++++++ ionic/routing/router.js | 338 +++++-------------------------- 3 files changed, 242 insertions(+), 284 deletions(-) create mode 100644 ionic/routing/path-recognizer.js diff --git a/ionic/components/app/app.js b/ionic/components/app/app.js index 981e730133..0250b35e27 100644 --- a/ionic/components/app/app.js +++ b/ionic/components/app/app.js @@ -20,6 +20,8 @@ export class IonicApp { // Our component registry map this.components = {}; + + this._activeViewId = null; } load(appRef) { @@ -46,7 +48,10 @@ export class IonicApp { } stateChange(activeView, viewCtrl) { - this.router.stateChange(activeView, viewCtrl); + if (this._activeViewId !== activeView.id) { + this.router.stateChange(activeView, viewCtrl); + this._activeViewId = activeView.id; + } } /** diff --git a/ionic/routing/path-recognizer.js b/ionic/routing/path-recognizer.js new file mode 100644 index 0000000000..1749a76810 --- /dev/null +++ b/ionic/routing/path-recognizer.js @@ -0,0 +1,181 @@ +import { + RegExp, + RegExpWrapper, + RegExpMatcherWrapper, + StringWrapper, + isPresent, + isBlank, + BaseException, + normalizeBlank +} from 'angular2/src/facade/lang'; +import { + Map, + MapWrapper, + StringMap, + StringMapWrapper, + List, + ListWrapper +} from 'angular2/src/facade/collection'; + + +class ContinuationSegment { + generate(params) { + return ''; + } +} + +class StaticSegment { + constructor(string) { + this.name = ''; + this.regex = escapeRegex(string); + } + + generate(params) { + return this.string; + } +} + +class DynamicSegment { + constructor(name) { + this.regex = "([^/]+)"; + } + + generate(params) { + if (!StringMapWrapper.contains(params, this.name)) { + throw new BaseException( + `Route generator for '${this.name}' was not included in parameters passed.`) + } + return normalizeBlank(StringMapWrapper.get(params, this.name)); + } +} + + +class StarSegment { + constructor(name) { + this.regex = "(.+)"; + } + + generate(params) { + return normalizeBlank(StringMapWrapper.get(params, this.name)); + } +} + + +var paramMatcher = RegExpWrapper.create("^:([^\/]+)$"); +var wildcardMatcher = RegExpWrapper.create("^\\*([^\/]+)$"); + +function parsePathString(route: string) { + // normalize route as not starting with a "/". Recognition will + // also normalize. + if (StringWrapper.startsWith(route, "/")) { + route = StringWrapper.substring(route, 1); + } + + var segments = splitBySlash(route); + var results = []; + var specificity = 0; + + // The "specificity" of a path is used to determine which route is used when multiple routes match + // a URL. + // Static segments (like "/foo") are the most specific, followed by dynamic segments (like + // "/:id"). Star segments + // add no specificity. Segments at the start of the path are more specific than proceeding ones. + // The code below uses place values to combine the different types of segments into a single + // integer that we can + // sort later. Each static segment is worth hundreds of points of specificity (10000, 9900, ..., + // 200), and each + // dynamic segment is worth single points of specificity (100, 99, ... 2). + if (segments.length > 98) { + throw new BaseException(`'${route}' has more than the maximum supported number of segments.`); + } + + var limit = segments.length - 1; + for (var i = 0; i <= limit; i++) { + var segment = segments[i], match; + + if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) { + results.push(new DynamicSegment(match[1])); + specificity += (100 - i); + } else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) { + results.push(new StarSegment(match[1])); + } else if (segment == '...') { + if (i < limit) { + // TODO (matsko): setup a proper error here ` + throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`); + } + results.push(new ContinuationSegment()); + } else if (segment.length > 0) { + results.push(new StaticSegment(segment)); + specificity += 100 * (100 - i); + } + } + + return {segments: results, specificity}; +} + +function splitBySlash(url: string): List { + return url.split('/'); +} + + +// represents something like '/foo/:bar' +export class PathRecognizer { + + constructor(path) { + this.segments = []; + + // TODO: use destructuring assignment + // see https://github.com/angular/ts2dart/issues/158 + var parsed = parsePathString(path); + var specificity = parsed['specificity']; + var segments = parsed['segments']; + var regexString = '^'; + + ListWrapper.forEach(segments, (segment) => { + if (segment instanceof ContinuationSegment) { + this.terminal = false; + } else { + regexString += '/' + segment.regex; + } + }); + + if (this.terminal) { + regexString += '$'; + } + + this.regex = RegExpWrapper.create(regexString); + this.segments = segments; + this.specificity = specificity; + } + + parseParams(url) { + var params = StringMapWrapper.create(); + var urlPart = url; + for (var i = 0; i < this.segments.length; i++) { + var segment = this.segments[i]; + if (segment instanceof ContinuationSegment) { + continue; + } + + var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart); + urlPart = StringWrapper.substring(urlPart, match[0].length); + if (segment.name.length > 0) { + StringMapWrapper.set(params, segment.name, match[1]); + } + } + + return params; + } + + generate(params) { + return ListWrapper.join( + ListWrapper.map(this.segments, (segment) => '/' + segment.generate(params)), ''); + } +} + +var specialCharacters = ['/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\']; +var escapeRe = RegExpWrapper.create('(\\' + specialCharacters.join('|\\') + ')', 'g'); + +function escapeRegex(string): string { + return StringWrapper.replaceAllMapped(string, escapeRe, (match) => { return "\\" + match; }); +} diff --git a/ionic/routing/router.js b/ionic/routing/router.js index d514aa957a..2cf624b362 100644 --- a/ionic/routing/router.js +++ b/ionic/routing/router.js @@ -1,17 +1,27 @@ +import { + RegExp, + RegExpWrapper, + RegExpMatcherWrapper, + StringWrapper, + isPresent, + isBlank, + BaseException, + normalizeBlank +} from 'angular2/src/facade/lang'; + import * as util from '../util/util'; +import {PathRecognizer} from './path-recognizer'; export class IonicRouter { constructor(config) { - this._routes = []; + this._routes = {}; this._viewCtrls = []; this.config(config); } app(app) { this._app = app; - - } config(config) { @@ -20,44 +30,42 @@ export class IonicRouter { } } - addRoute(name, routeConfig) { - if (name && routeConfig && routeConfig.path) { - let route = new Route(name, routeConfig); - this._routes.push(route); + addRoute(routeName, routeConfig) { + if (routeName && routeConfig && routeConfig.path) { + this._routes[routeName] = new Route(routeName, routeConfig) } } init() { let rootViewCtrl = this.activeViewController(); if (rootViewCtrl) { - let matchedRoute = this.match() || this.otherwise(); + let matchedRoute = this.match( this.getCurrentPath() ) || this.otherwise(); this.push(rootViewCtrl, matchedRoute); } } - match() { - let path = this.getCurrentPath(); - let route = null; + match(path) { + let matchedRoute = null; + let routeMatch = null; + let highestSpecifity = 0; - for (let i = 0, ii = this._routes.length; i < ii; i++) { - route = this._routes[i]; - if (route.match(path)) { - return route; + for (let routeName in this._routes) { + routeMatch = this._routes[routeName].match(path); + + if (routeMatch.match && (!matchedRoute || routeMatch.specificity > highestSpecifity)) { + matchedRoute = this._routes[routeName]; + highestSpecifity = routeMatch.specificity; } } + return matchedRoute; } otherwise(val) { if (arguments.length) { this._otherwise = val; - } else { - let route = null; - for (let i = 0, ii = this._routes.length; i < ii; i++) { - route = this._routes[i]; - if (route.name === this._otherwise) { - return route; - } - } + + } else if (this._otherwise) { + return this._routes[this._otherwise]; } } @@ -66,9 +74,7 @@ export class IonicRouter { function run() { self._app.zone().run(() => { - viewCtrl.push(route.cls).then(() => { - self.updateState(route); - }); + viewCtrl.push(route.cls); }); } @@ -88,24 +94,25 @@ export class IonicRouter { } stateChange(activeView) { - console.log('stateChange', activeView); + if (activeView && activeView.ComponentType) { - let routeConfig = activeView.ComponentType.route; + let routeConfig = activeView.ComponentType.route; + if (routeConfig) { + let matchedRoute = this.match(routeConfig.path); - let route = null; - for (let i = 0, ii = this._routes.length; i < ii; i++) { - route = this._routes[i]; - - if (route.path == routeConfig.path) { - return this.updateState(route); + if (matchedRoute) { + this.updateState(matchedRoute); + } } } } updateState(route) { - console.log('updateState', route); - - window.location.hash = route.path; + let newPath = route.path; + if (window.location.hash !== '#' + newPath) { + console.log('updateState', newPath); + window.location.hash = newPath; + } } addViewController(viewCtrl) { @@ -121,10 +128,8 @@ export class IonicRouter { getCurrentPath() { let hash = window.location.hash; - // Grab the path without the leading hash - let path = hash.slice(1); - return path; + return hash.slice(1); } } @@ -142,256 +147,23 @@ class Route { this.name = name; this.cls = null; util.extend(this, routeConfig); + this.recognizer = new PathRecognizer(this.path); } - match(path) { - if (this.path) { - if (this.path == path) { - return true; - } + match(matchPath) { + let routeMatch = new RouteMatch(this, matchPath); + if (routeMatch) { + return routeMatch; } return false; } } - - - - -/** - * The RouterController handles checking for matches of - * each registered route, and triggering callbacks, gathering - * route param data, etc. - */ -export class RouterController { - constructor() { - this.routes = [] - } - - // Build route params to send to the matching route. - _buildRouteParams(routeParams) { - routeParams._route = { - path: window.location.hash.slice(1) - } - return routeParams; - } - - // Called when there is no match - _noMatch() { - // otherwise()? - return {} - } - - setNavController(navController) { - this.rootNavController = navController; - console.log('Root nav controller set', navController); - this.run(); - } - - getCurrentPath() { - let hash = window.location.hash; - - // Grab the path without the leading hash - let path = hash.slice(1); - return path; - } - - push(componentClass, params) { - // if(!this.rootNavController) { - // console.error('Router: No root nav controller to push matching route.'); - // return; - // } - // console.log('Router pushing', componentClass, params); - // setTimeout(() => { - // this.rootNavController.push(componentClass, params); - // }); - } - - run() { - this.match(); - } - - /** - * Try to match a single route. - */ - matchOne(route) { - console.log('Match one', route); - let path = this.getCurrentPath(); - let routeParams = route.match(path); - - if(routeParams !== false) { - route.exec(this._buildRouteParams(routeParams)); - - // If the route has a registered URL and isn't set to quiet mode, - // emit the new URL into the address bar - if(route.url && !route.quiet) { - this.emit(route.url); - } - - return - } - } - - /** - * Check the current hash/location for a match with - * registered routes. If a match is found, execute the - * first one and then return. - */ - match() { - let path = this.getCurrentPath(); - - let routeParams = {}; - - for(let route of this.routes) { - - routeParams = route.match(path); - - if(routeParams !== false) { - route.exec(this._buildRouteParams(routeParams)); - - /* - // If the route has a registered URL and isn't set to quiet mode, - // emit the new URL into the address bar - if(route.url && !route.quiet) { - this.emit(route.url); - } - */ - - return - } - } - - return this._noMatch(); - } - - /** - * Emit the current path to the address bar, either - * as part of the hash or pop/push state if using - * html5 routing style. - */ - emit(path) { - window.location.hash = path - } - - /** - * Register a new route. - * @param path the path to watch for - * @param cb the callback to execute - */ - on(path, cb) { - let route = new Route(path, cb); - this.routes.push(route); - //this.matchOne(route); - return route; - } - - - /** - * If no routes match, trigger the one that matches - * the "otherwise" condition. - */ - otherwise(path) { - let routeParams = {} - for(let route of this.routes) { - if((routeParams = route.match(path)) !== false) { - console.log('OTHERWISE: route matched:', route.url); - route.exec(routeParams) - this.emit(route.url) - } - } +class RouteMatch { + constructor(route, matchPath) { + this.route = route; + this.specificity = route.recognizer.specificity; + this.match = RegExpWrapper.firstMatch(route.recognizer.regex, matchPath); } } - -export class Route_OLD { - constructor(url, handler) { - this.url = url; - this.handler = handler; - } - match(path) { - let routeParams = {} - - // Either we have a direct string match, or - // we need to check the route more deeply - // Example: /tab/home - if(this.url == path) { - return {} - } else if((routeParams = this._matchParams(path))) { - return routeParams - } - return false - } - - _matchParams(path) { - var parts = path.split('/'); - var routeParts = this.url.split('/'); - - // Our aggregated route params that matched our route path. - // This is used for things like /post/:id - var routeParams = {}; - - if(parts.length !== routeParts.length) { - // Can't possibly match if the lengths are different - return false; - } - - // Otherwise, we need to check each part - - let rp, pp; - for(let i in parts) { - pp = parts[i]; - rp = routeParts[i]; - - if(rp[0] == ':') { - // We have a route param, store it in our - // route params without the colon - routeParams[rp.slice(1)] = pp; - } else if(pp !== rp) { - return false; - } - - } - return routeParams; - } - exec(matchParams) { - this.handler(matchParams) - } -} - -/** - * Routable is metadata added to routable things in Ionic. - * This makes it easy to auto emit URLs for routables pushed - * onto the stack. - */ -// export class Routable { -// constructor(componentClass, routeInfo) { -// this.componentClass = componentClass; -// this.routeInfo = routeInfo; - -// //console.log('New routable', componentClass, routeInfo); -// Router_OLD.on(this.routeInfo.url, (routeParams) => { -// console.log('Routable matched', routeParams, this.componentClass); -// Router_OLD.push(this.componentClass, routeParams); -// }); - -// componentClass.router = this; -// } -// invoke(componentInstance) { -// // Called on viewLoaded -// this.componentInstance = componentInstance; - -// // Bind some lifecycle events -// componentInstance._viewWillEnter.observer({ -// next: () => { -// Router_OLD.emit(this.routeInfo.url); -// } -// }); - -// return this; -// } - -// } - -export var Router_OLD = new RouterController(); - -//export { IonicRouter, Router_OLD, Route, Routable };