first commit
[ratchet] / lib / js / push.js
1 /* ----------------------------------
2  * PUSH v1.0.0
3  * Licensed under The MIT License
4  * inspired by chris's jquery.pjax.js
5  * http://opensource.org/licenses/MIT
6  * ---------------------------------- */
7
8 !function () {
9
10   var noop = function () {};
11
12
13   // Pushstate cacheing
14   // ==================
15
16   var isScrolling;
17   var maxCacheLength = 20;
18   var cacheMapping   = sessionStorage;
19   var domCache       = {};
20   var transitionMap  = {
21     'slide-in'  : 'slide-out',
22     'slide-out' : 'slide-in',
23     'fade'      : 'fade'
24   };
25   var bars = {
26     bartab             : '.bar-tab',
27     bartitle           : '.bar-title',
28     barfooter          : '.bar-footer',
29     barheadersecondary : '.bar-header-secondary'
30   }
31
32   var cacheReplace = function (data, updates) {
33     PUSH.id = data.id;
34     if (updates) data = getCached(data.id);
35     cacheMapping[data.id] = JSON.stringify(data);
36     window.history.replaceState(data.id, data.title, data.url);
37     domCache[data.id] = document.body.cloneNode(true);
38   };
39
40   var cachePush = function () {
41     var id = PUSH.id;
42
43     var cacheForwardStack = JSON.parse(cacheMapping.cacheForwardStack || '[]');
44     var cacheBackStack    = JSON.parse(cacheMapping.cacheBackStack    || '[]');
45
46     cacheBackStack.push(id);
47
48     while (cacheForwardStack.length)               delete cacheMapping[cacheForwardStack.shift()];
49     while (cacheBackStack.length > maxCacheLength) delete cacheMapping[cacheBackStack.shift()];
50
51     window.history.pushState(null, '', cacheMapping[PUSH.id].url);
52
53     cacheMapping.cacheForwardStack = JSON.stringify(cacheForwardStack);
54     cacheMapping.cacheBackStack    = JSON.stringify(cacheBackStack);
55   };
56
57   var cachePop = function (id, direction) {
58     var forward           = direction == 'forward';
59     var cacheForwardStack = JSON.parse(cacheMapping.cacheForwardStack || '[]');
60     var cacheBackStack    = JSON.parse(cacheMapping.cacheBackStack    || '[]');
61     var pushStack         = forward ? cacheBackStack    : cacheForwardStack;
62     var popStack          = forward ? cacheForwardStack : cacheBackStack;
63
64     if (PUSH.id) pushStack.push(PUSH.id);
65     popStack.pop();
66
67     cacheMapping.cacheForwardStack = JSON.stringify(cacheForwardStack);
68     cacheMapping.cacheBackStack    = JSON.stringify(cacheBackStack);
69   };
70
71   var getCached = function (id) {
72     return JSON.parse(cacheMapping[id] || null) || {};
73   };
74
75   var getTarget = function (e) {
76     var target = findTarget(e.target);
77
78     if (
79       !  target
80       || e.which > 1
81       || e.metaKey
82       || e.ctrlKey
83       || isScrolling
84       || location.protocol !== target.protocol
85       || location.host     !== target.host
86       || !target.hash && /#/.test(target.href)
87       || target.hash && target.href.replace(target.hash, '') === location.href.replace(location.hash, '')
88       || target.getAttribute('data-ignore') == 'push'
89     ) return;
90
91     return target;
92   };
93
94
95   // Main event handlers (touchend, popstate)
96   // ==========================================
97
98   var touchend = function (e) {
99     var target = getTarget(e);
100
101     if (!target) return;
102
103     e.preventDefault();
104
105     PUSH({
106       url        : target.href,
107       hash       : target.hash,
108       timeout    : target.getAttribute('data-timeout'),
109       transition : target.getAttribute('data-transition')
110     });
111   };
112
113   var popstate = function (e) {
114     var key;
115     var barElement;
116     var activeObj;
117     var activeDom;
118     var direction;
119     var transition;
120     var transitionFrom;
121     var transitionFromObj;
122     var id = e.state;
123
124     if (!id || !cacheMapping[id]) return;
125
126     direction = PUSH.id < id ? 'forward' : 'back';
127
128     cachePop(id, direction);
129
130     activeObj = getCached(id);
131     activeDom = domCache[id];
132
133     if (activeObj.title) document.title = activeObj.title;
134
135     if (direction == 'back') {
136       transitionFrom    = JSON.parse(direction == 'back' ? cacheMapping.cacheForwardStack : cacheMapping.cacheBackStack);
137       transitionFromObj = getCached(transitionFrom[transitionFrom.length - 1]);
138     } else {
139       transitionFromObj = activeObj;
140     }
141
142     if (direction == 'back' && !transitionFromObj.id) return PUSH.id = id;
143
144     transition = direction == 'back' ? transitionMap[transitionFromObj.transition] : transitionFromObj.transition;
145
146     if (!activeDom) {
147       return PUSH({
148         id         : activeObj.id,
149         url        : activeObj.url,
150         title      : activeObj.title,
151         timeout    : activeObj.timeout,
152         transition : transition,
153         ignorePush : true
154       });
155     }
156
157     if (transitionFromObj.transition) {
158       activeObj = extendWithDom(activeObj, '.content', activeDom.cloneNode(true));
159       for (key in bars) {
160         barElement = document.querySelector(bars[key])
161         if (activeObj[key]) swapContent(activeObj[key], barElement);
162         else if (barElement) barElement.parentNode.removeChild(barElement);
163       }
164     }
165
166     swapContent(
167       (activeObj.contents || activeDom).cloneNode(true),
168       document.querySelector('.content'),
169       transition
170     );
171
172     PUSH.id = id;
173
174     document.body.offsetHeight; // force reflow to prevent scroll
175   };
176
177
178   // Core PUSH functionality
179   // =======================
180
181   var PUSH = function (options) {
182     var key;
183     var data = {};
184     var xhr  = PUSH.xhr;
185
186     options.container = options.container || options.transition ? document.querySelector('.content') : document.body;
187
188     for (key in bars) {
189       options[key] = options[key] || document.querySelector(bars[key]);
190     }
191
192     if (xhr && xhr.readyState < 4) {
193       xhr.onreadystatechange = noop;
194       xhr.abort()
195     }
196
197     xhr = new XMLHttpRequest();
198     xhr.open('GET', options.url, true);
199     xhr.setRequestHeader('X-PUSH', 'true');
200
201     xhr.onreadystatechange = function () {
202       if (options._timeout) clearTimeout(options._timeout);
203       if (xhr.readyState == 4) xhr.status == 200 ? success(xhr, options) : failure(options.url);
204     };
205
206     if (!PUSH.id) {
207       cacheReplace({
208         id         : +new Date,
209         url        : window.location.href,
210         title      : document.title,
211         timeout    : options.timeout,
212         transition : null
213       });
214     }
215
216     if (options.timeout) {
217       options._timeout = setTimeout(function () {  xhr.abort('timeout'); }, options.timeout);
218     }
219
220     xhr.send();
221
222     if (xhr.readyState && !options.ignorePush) cachePush();
223   };
224
225
226   // Main XHR handlers
227   // =================
228
229   var success = function (xhr, options) {
230     var key;
231     var barElement;
232     var data = parseXHR(xhr, options);
233
234     if (!data.contents) return locationReplace(options.url);
235
236     if (data.title) document.title = data.title;
237
238     if (options.transition) {
239       for (key in bars) {
240         barElement = document.querySelector(bars[key])
241         if (data[key]) swapContent(data[key], barElement);
242         else if (barElement) barElement.parentNode.removeChild(barElement);
243       }
244     }
245
246     swapContent(data.contents, options.container, options.transition, function () {
247       cacheReplace({
248         id         : options.id || +new Date,
249         url        : data.url,
250         title      : data.title,
251         timeout    : options.timeout,
252         transition : options.transition
253       }, options.id);
254       triggerStateChange();
255     });
256
257     if (!options.ignorePush && window._gaq) _gaq.push(['_trackPageview']) // google analytics
258     if (!options.hash) return;
259   };
260
261   var failure = function (url) {
262     throw new Error('Could not get: ' + url)
263   };
264
265
266   // PUSH helpers
267   // ============
268
269   var swapContent = function (swap, container, transition, complete) {
270     var enter;
271     var containerDirection;
272     var swapDirection;
273
274     if (!transition) {
275       if (container) container.innerHTML = swap.innerHTML;
276       else if (swap.classList.contains('content')) document.body.appendChild(swap);
277       else document.body.insertBefore(swap, document.querySelector('.content'));
278     } else {
279       enter  = /in$/.test(transition);
280
281       if (transition == 'fade') {
282         container.classList.add('in');
283         container.classList.add('fade');
284         swap.classList.add('fade');
285       }
286
287       if (/slide/.test(transition)) {
288         swap.classList.add(enter ? 'right' : 'left');
289         swap.classList.add('slide');
290         container.classList.add('slide');
291       }
292
293       container.parentNode.insertBefore(swap, container);
294     }
295
296     if (!transition) complete && complete();
297
298     if (transition == 'fade') {
299       container.offsetWidth; // force reflow
300       container.classList.remove('in');
301       container.addEventListener('webkitTransitionEnd', fadeContainerEnd);
302
303       function fadeContainerEnd() {
304         container.removeEventListener('webkitTransitionEnd', fadeContainerEnd);
305         swap.classList.add('in');
306         swap.addEventListener('webkitTransitionEnd', fadeSwapEnd);
307       }
308       function fadeSwapEnd () {
309         swap.removeEventListener('webkitTransitionEnd', fadeSwapEnd);
310         container.parentNode.removeChild(container);
311         swap.classList.remove('fade');
312         swap.classList.remove('in');
313         complete && complete();
314       }
315     }
316
317     if (/slide/.test(transition)) {
318       container.offsetWidth; // force reflow
319       swapDirection      = enter ? 'right' : 'left'
320       containerDirection = enter ? 'left' : 'right'
321       container.classList.add(containerDirection);
322       swap.classList.remove(swapDirection);
323       swap.addEventListener('webkitTransitionEnd', slideEnd);
324
325       function slideEnd() {
326         swap.removeEventListener('webkitTransitionEnd', slideEnd);
327         swap.classList.remove('slide');
328         swap.classList.remove(swapDirection);
329         container.parentNode.removeChild(container);
330         complete && complete();
331       }
332     }
333   };
334
335   var triggerStateChange = function () {
336     var e = new CustomEvent('push', {
337       detail: { state: getCached(PUSH.id) },
338       bubbles: true,
339       cancelable: true
340     });
341
342     window.dispatchEvent(e);
343   };
344
345   var findTarget = function (target) {
346     var i, toggles = document.querySelectorAll('a');
347     for (; target && target !== document; target = target.parentNode) {
348       for (i = toggles.length; i--;) { if (toggles[i] === target) return target; }
349     }
350   };
351
352   var locationReplace = function (url) {
353     window.history.replaceState(null, '', '#');
354     window.location.replace(url);
355   };
356
357   var parseURL = function (url) {
358     var a = document.createElement('a'); a.href = url; return a;
359   };
360
361   var extendWithDom = function (obj, fragment, dom) {
362     var i;
363     var result    = {};
364
365     for (i in obj) result[i] = obj[i];
366
367     Object.keys(bars).forEach(function (key) {
368       var el = dom.querySelector(bars[key]);
369       if (el) el.parentNode.removeChild(el);
370       result[key] = el;
371     });
372
373     result.contents = dom.querySelector(fragment);
374
375     return result;
376   };
377
378   var parseXHR = function (xhr, options) {
379     var head;
380     var body;
381     var data = {};
382     var responseText = xhr.responseText;
383
384     data.url = options.url;
385
386     if (!responseText) return data;
387
388     if (/<html/i.test(responseText)) {
389       head           = document.createElement('div');
390       body           = document.createElement('div');
391       head.innerHTML = responseText.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0]
392       body.innerHTML = responseText.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]
393     } else {
394       head           = body = document.createElement('div');
395       head.innerHTML = responseText;
396     }
397
398     data.title = head.querySelector('title');
399     data.title = data.title && data.title.innerText.trim();
400
401     if (options.transition) data = extendWithDom(data, '.content', body);
402     else data.contents = body;
403
404     return data;
405   };
406
407
408   // Attach PUSH event handlers
409   // ==========================
410
411   window.addEventListener('touchstart', function () { isScrolling = false; });
412   window.addEventListener('touchmove', function () { isScrolling = true; })
413   window.addEventListener('touchend', touchend);
414   window.addEventListener('click', function (e) { if (getTarget(e)) e.preventDefault(); });
415   window.addEventListener('popstate', popstate);
416
417 }();