can
Version:
MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.
240 lines (208 loc) • 7.57 kB
JavaScript
// # can/route/pushstate/pushstate.js
//
// Plugin for `can.route` which uses browser `history.pushState` support
// to update window's pathname instead of the `hash`.
//
// It registers itself as binding on `can.route`, intercepts `click` events
// on `<a>` elements across document and accordingly updates `can.route` state
// and window's pathname.
/*jshint maxdepth:6, scripturl:true*/
steal('can/util', 'can/route', function (can) {
"use strict";
var hasPushstate = window.history && window.history.pushState;
var isFileProtocol = window.location && window.location.protocol === 'file:';
// Initialize plugin only if browser supports pushstate.
if (!isFileProtocol && hasPushstate) {
// Registers itself within `can.route.bindings`.
can.route.bindings.pushstate = {
/**
* @property {String} can.route.pushstate.root
* @parent can.route.pushstate
*
* @description Configure the base url that will not be modified.
*
* @option {String} Represents the base url that pushstate will prepend to all
* routes. `root` defaults to: `"/"`.
*
* @body
*
* ## Use
*
* By default, a route like:
*
* can.route(":type/:id")
*
* Matches urls like:
*
* http://domain.com/contact/5
*
* But sometimes, you only want to match pages within a certain directory. For
* example, an application that is a filemanager. You might want to
* specify root and routes like:
*
* can.route.pushstate.root = "/filemanager/"
* can.route("file-:fileId");
* can.route("folder-:fileId")
*
* Which matches urls like:
*
* http://domain.com/filemanager/file-34234
*
*/
// Start of `location.pathname` is the root.
// (Can be configured via `can.route.bindings.pushstate.root`)
root: "/",
// don't greedily match slashes in routing rules
matchSlashes: false,
paramsMatcher: /^\?(?:[^=]+=[^&]*&)*[^=]+=[^&]*/,
querySeparator: '?',
// ## bind
// Intercepts clicks on `<a>` elements and rewrites original `history` methods.
bind: function () {
if(can.isNode) {
return;
}
// Intercept routable links.
can.delegate.call(can.$(document.documentElement), 'a', 'click', anchorClickHandler);
// Rewrites original `pushState`/`replaceState` methods on `history` and keeps pointer to original methods
can.each(methodsToOverwrite, function (method) {
originalMethods[method] = window.history[method];
window.history[method] = function (state, title, url) {
// Avoid doubled history states (with pushState).
var absolute = url.indexOf("http") === 0;
var searchHash = window.location.search + window.location.hash;
// If url differs from current call original histoy method and update `can.route` state.
if ((!absolute && url !== window.location.pathname + searchHash) || (absolute && url !== window.location.href + searchHash)) {
originalMethods[method].apply(window.history, arguments);
can.route.setState();
}
};
});
// Bind to `popstate` event, fires on back/forward.
can.bind.call(window, 'popstate', can.route.setState);
},
// ## unbind
// Unbinds and restores original `history` methods
unbind: function () {
can.undelegate.call(can.$(document.documentElement), 'click', 'a', anchorClickHandler);
can.each(methodsToOverwrite, function (method) {
window.history[method] = originalMethods[method];
});
can.unbind.call(window, 'popstate', can.route.setState);
},
// ## matchingPartOfURL
// Returns matching part of url without root.
matchingPartOfURL: function () {
var root = cleanRoot(),
loc = (location.pathname + location.search),
index = loc.indexOf(root);
return loc.substr(index + root.length);
},
// ## setURL
// Updates URL by calling `pushState`.
setURL: function (path, changed) {
var method = "pushState";
// Keeps hash if not in path.
if (includeHash && path.indexOf("#") === -1 && window.location.hash) {
path += window.location.hash;
}
if(replaceStateAttrs.length > 0) {
var toRemove = [];
for(var i = 0, l = changed.length; i < l; i++) {
if(can.inArray(changed[i], replaceStateAttrs) !== -1) {
method = "replaceState";
}
if(can.inArray(changed[i], replaceStateAttrs.once) !== -1) {
toRemove.push(changed[i]);
}
}
if(toRemove.length > 0) {
removeAttrs(replaceStateAttrs, toRemove);
removeAttrs(replaceStateAttrs.once, toRemove);
}
}
window.history[method](null, null, can.route._call("root") + path);
}
};
// ## anchorClickHandler
// Handler function for `click` events.
var anchorClickHandler = function (e) {
if (!(e.isDefaultPrevented ? e.isDefaultPrevented() : e.defaultPrevented === true)) {
// YUI calls back events triggered with this as a wrapped object.
var node = this._node || this;
// Fix for IE showing blank host, but blank host means current host.
var linksHost = node.host || window.location.host;
if(node.href === "javascript://") {
return;
}
// If link is within the same domain and descendant of `root`
if (window.location.host === linksHost) {
var root = cleanRoot();
if (node.pathname.indexOf(root) === 0) {
// Removes root from url.
var url = (node.pathname + node.search).substr(root.length);
// If a route matches update the data.
var curParams = can.route.deparam(url);
if (curParams.hasOwnProperty('route')) {
// Makes it possible to have a link with a hash.
includeHash = true;
window.history.pushState(null, null, node.href);
// Test if you can preventDefault
// our tests can't call .click() b/c this
// freezes phantom.
if (e.preventDefault) {
e.preventDefault();
}
}
}
}
}
},
// ## cleanRoot
// Always returns clean root, without domain.
cleanRoot = function () {
var domain = location.protocol + "//" + location.host,
root = can.route._call("root"),
index = root.indexOf(domain);
if (index === 0) {
return root.substr(domain.length);
}
return root;
},
removeAttrs = function(arr, attrs) {
var index;
for(var i = attrs.length - 1; i >= 0; i--) {
if( (index = can.inArray(attrs[i], arr)) !== -1) {
arr.splice(index, 1);
}
}
},
// Original methods on `history` that will be overwritten
methodsToOverwrite = ['pushState', 'replaceState'],
// A place to store pointers to original `history` methods.
originalMethods = {},
// Used to tell setURL to include the hash because we clicked on a link.
includeHash = false,
// Attributes that will cause replaceState to be called
replaceStateAttrs = [];
// Enables plugin, by default `hashchange` binding is used.
can.route.defaultBinding = "pushstate";
can.extend(can.route, {
replaceStateOn: function() {
var attrs = can.makeArray(arguments);
Array.prototype.push.apply(replaceStateAttrs, attrs);
},
replaceStateOnce: function() {
var attrs = can.makeArray(arguments);
replaceStateAttrs.once = can.makeArray(replaceStateAttrs.once);
Array.prototype.push.apply(replaceStateAttrs.once, attrs);
can.route.replaceStateOn.apply(this, arguments);
},
replaceStateOff: function() {
var attrs = can.makeArray(arguments);
removeAttrs(replaceStateAttrs, attrs);
}
});
}
return can;
});