2 Copyright 2010 Google Inc.
4 Licensed under the Apache License, Version 2.0 (the "License");
5 you may not use this file except in compliance with the License.
6 You may obtain a copy of the License at
8 http://www.apache.org/licenses/LICENSE-2.0
10 Unless required by applicable law or agreed to in writing, software
11 distributed under the License is distributed on an "AS IS" BASIS,
12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 See the License for the specific language governing permissions and
14 limitations under the License.
18 * @fileoverview Bookmark bubble library. This is meant to be included in the
19 * main JavaScript binary of a mobile web application.
21 * Supported browsers: iPhone / iPod / iPad Safari 3.0+
24 var google = google || {};
25 google.bookmarkbubble = google.bookmarkbubble || {};
29 * Binds a context object to the function.
30 * @param {Function} fn The function to bind to.
31 * @param {Object} context The "this" object to use when the function is run.
32 * @return {Function} A partially-applied form of fn.
34 google.bind = function(fn, context) {
36 return fn.apply(context, arguments);
42 * Function used to define an abstract method in a base class. If a subclass
43 * fails to override the abstract method, then an error will be thrown whenever
44 * that method is invoked.
46 google.abstractMethod = function() {
47 throw Error('Unimplemented abstract method.');
53 * The bubble constructor. Instantiating an object does not cause anything to
54 * be rendered yet, so if necessary you can set instance properties before
58 google.bookmarkbubble.Bubble = function() {
60 * Handler for the scroll event. Keep a reference to it here, so it can be
61 * unregistered when the bubble is destroyed.
65 this.boundScrollHandler_ = google.bind(this.setPosition, this);
75 * Whether the bubble has been destroyed.
79 this.hasBeenDestroyed_ = false;
84 * Shows the bubble if allowed. It is not allowed if:
85 * - The browser is not Mobile Safari, or
86 * - The user has dismissed it too often already, or
87 * - The hash parameter is present in the location hash, or
88 * - The application is in fullscreen mode, which means it was already loaded
89 * from a homescreen bookmark.
90 * @return {boolean} True if the bubble is being shown, false if it is not
91 * allowed to show for one of the aforementioned reasons.
93 google.bookmarkbubble.Bubble.prototype.showIfAllowed = function() {
94 if (!this.isAllowedToShow_()) {
104 * Shows the bubble if allowed after loading the icon image. This method creates
105 * an image element to load the image into the browser's cache before showing
106 * the bubble to ensure that the image isn't blank. Use this instead of
107 * showIfAllowed if the image url is http and cacheable.
108 * This hack is necessary because Mobile Safari does not properly render
109 * image elements with border-radius CSS.
110 * @param {function()} opt_callback Closure to be called if and when the bubble
112 * @return {boolean} True if the bubble is allowed to show.
114 google.bookmarkbubble.Bubble.prototype.showIfAllowedWhenLoaded =
115 function(opt_callback) {
116 if (!this.isAllowedToShow_()) {
121 // Attach to self to avoid garbage collection.
122 var img = self.loadImg_ = document.createElement('img');
123 img.src = self.getIconUrl_();
124 img.onload = function() {
126 delete self.loadImg_;
127 img.onload = null; // Break the circular reference.
130 opt_callback && opt_callback();
140 * Sets the parameter in the location hash. As it is
141 * unpredictable what hash scheme is to be used, this method must be
142 * implemented by the host application.
144 * This gets called automatically when the bubble is shown. The idea is that if
145 * the user then creates a bookmark, we can later recognize on application
146 * startup whether it was from a bookmark suggested with this bubble.
148 google.bookmarkbubble.Bubble.prototype.setHashParameter = google.abstractMethod;
152 * Whether the parameter is present in the location hash. As it is
153 * unpredictable what hash scheme is to be used, this method must be
154 * implemented by the host application.
156 * Call this method during application startup if you want to log whether the
157 * application was loaded from a bookmark with the bookmark bubble promotion
160 * @return {boolean} Whether the bookmark bubble parameter is present in the
163 google.bookmarkbubble.Bubble.prototype.hasHashParameter = google.abstractMethod;
167 * The number of times the user must dismiss the bubble before we stop showing
168 * it. This is a public property and can be changed by the host application if
172 google.bookmarkbubble.Bubble.prototype.NUMBER_OF_TIMES_TO_DISMISS = 2;
176 * Time in milliseconds. If the user does not dismiss the bubble, it will auto
177 * destruct after this amount of time.
180 google.bookmarkbubble.Bubble.prototype.TIME_UNTIL_AUTO_DESTRUCT = 15000;
184 * The prefix for keys in local storage. This is a public property and can be
185 * changed by the host application if necessary.
188 google.bookmarkbubble.Bubble.prototype.LOCAL_STORAGE_PREFIX = 'BOOKMARK_';
192 * The key name for the dismissed state.
196 google.bookmarkbubble.Bubble.prototype.DISMISSED_ = 'DISMISSED_COUNT';
200 * The arrow image in base64 data url format.
204 google.bookmarkbubble.Bubble.prototype.IMAGE_ARROW_DATA_URL_ = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAATCAMAAABSrFY3AAABKVBMVEUAAAD///8AAAAAAAAAAAAAAAAAAADf398AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD09PQAAAAAAAAAAAC9vb0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD19fUAAAAAAAAAAAAAAADq6uoAAAAAAAAAAAC8vLzU1NTT09MAAADg4OAAAADs7OwAAAAAAAAAAAD///+cueenwerA0vC1y+3a5fb5+/3t8vr4+v3w9PuwyOy3zO3h6vfh6vjq8Pqkv+mat+fE1fHB0/Cduuifu+iuxuuivemrxOvC1PDz9vzJ2fKpwuqmwOrb5vapw+q/0vDf6ffK2vLN3PPprJISAAAAQHRSTlMAAAEGExES7FM+JhUoQSxIRwMbNfkJUgXXBE4kDQIMHSA0Tw4xIToeTSc4Chz4OyIjPfI3QD/X5OZR6zzwLSUPrm1y3gAAAQZJREFUeF5lzsVyw0AURNE3IMsgmZmZgszQZoeZOf//EYlG5Yrhbs+im4Dj7slM5wBJ4OJ+undAUr68gK/Hyb6Bcp5yBR/w8jreNeAr5Eg2XE7g6e2/0z6cGw1JQhpmHP3u5aiPPnTTkIK48Hj9Op7bD3btAXTfgUdwYjwSDCVXMbizO0O4uDY/x4kYC5SWFnfC6N1a9RCO7i2XEmQJj2mHK1Hgp9Vq3QBRl9shuBLGhcNtHexcdQCnDUoUGetxDD+H2DQNG2xh6uAWgG2/17o1EmLqYH0Xej0UjHAaFxZIV6rJ/WK1kg7QZH8HU02zmdJinKZJaDV3TVMjM5Q9yiqYpUwiMwa/1apDXTNESjsAAAAASUVORK5CYII=';
208 * The close image in base64 data url format.
212 google.bookmarkbubble.Bubble.prototype.IMAGE_CLOSE_DATA_URL_ = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAALVBMVEXM3fm+1Pfb5/rF2fjw9f23z/aavPOhwfTp8PyTt/L3+v7T4vqMs/K7zP////+qRWzhAAAAXElEQVQIW2O4CwUM996BwVskxtOqd++2rwMyPI+ve31GD8h4Madqz2mwms5jZ/aBGS/mHIDoen3m+DowY8/hOVUgxusz+zqPg7SvPA1UxQfSvu/du0YUK2AMmDMA5H1qhVX33T8AAAAASUVORK5CYII=';
216 * The link used to locate the application's home screen icon to display inside
217 * the bubble. The default link used here is for an iPhone home screen icon
218 * without gloss. If your application uses a glossy icon, change this to
219 * 'apple-touch-icon'.
223 google.bookmarkbubble.Bubble.prototype.REL_ICON_ =
224 'apple-touch-icon-precomposed';
228 * Regular expression for detecting an iPhone or iPod or iPad.
232 google.bookmarkbubble.Bubble.prototype.MOBILE_SAFARI_USERAGENT_REGEX_ =
237 * Regular expression for detecting an iPad.
241 google.bookmarkbubble.Bubble.prototype.IPAD_USERAGENT_REGEX_ = /iPad/;
245 * Determines whether the bubble should be shown or not.
246 * @return {boolean} Whether the bubble should be shown or not.
249 google.bookmarkbubble.Bubble.prototype.isAllowedToShow_ = function() {
250 return this.isMobileSafari_() &&
251 !this.hasBeenDismissedTooManyTimes_() &&
252 !this.isFullscreen_() &&
253 !this.hasHashParameter();
258 * Builds and shows the bubble.
261 google.bookmarkbubble.Bubble.prototype.show_ = function() {
262 this.element_ = this.build_();
264 document.body.appendChild(this.element_);
265 this.element_.style.WebkitTransform =
266 'translateY(' + this.getHiddenYPosition_() + 'px)';
268 this.setHashParameter();
270 window.setTimeout(this.boundScrollHandler_, 1);
271 window.addEventListener('scroll', this.boundScrollHandler_, false);
273 // If the user does not dismiss the bubble, slide out and destroy it after
275 window.setTimeout(google.bind(this.autoDestruct_, this),
276 this.TIME_UNTIL_AUTO_DESTRUCT);
281 * Destroys the bubble by removing its DOM nodes from the document.
283 google.bookmarkbubble.Bubble.prototype.destroy = function() {
284 if (this.hasBeenDestroyed_) {
287 window.removeEventListener('scroll', this.boundScrollHandler_, false);
288 if (this.element_ && this.element_.parentNode == document.body) {
289 document.body.removeChild(this.element_);
290 this.element_ = null;
292 this.hasBeenDestroyed_ = true;
297 * Remember that the user has dismissed the bubble once more.
300 google.bookmarkbubble.Bubble.prototype.rememberDismissal_ = function() {
301 if (window.localStorage) {
303 var key = this.LOCAL_STORAGE_PREFIX + this.DISMISSED_;
304 var value = Number(window.localStorage[key]) || 0;
305 window.localStorage[key] = String(value + 1);
307 // Looks like we've hit the storage size limit. Currently we have no
308 // fallback for this scenario, but we could use cookie storage instead.
309 // This would increase the code bloat though.
316 * Whether the user has dismissed the bubble often enough that we will not
318 * @return {boolean} Whether the user has dismissed the bubble often enough
319 * that we will not show it again.
322 google.bookmarkbubble.Bubble.prototype.hasBeenDismissedTooManyTimes_ =
324 if (!window.localStorage) {
325 // If we can not use localStorage to remember how many times the user has
326 // dismissed the bubble, assume he has dismissed it. Otherwise we might end
327 // up showing it every time the host application loads, into eternity.
331 var key = this.LOCAL_STORAGE_PREFIX + this.DISMISSED_;
333 // If the key has never been set, localStorage yields undefined, which
334 // Number() turns into NaN. In that case we'll fall back to zero for
336 var value = Number(window.localStorage[key]) || 0;
338 return value >= this.NUMBER_OF_TIMES_TO_DISMISS;
340 // If we got here, something is wrong with the localStorage. Make the same
341 // assumption as when it does not exist at all. Exceptions should only
342 // occur when setting a value (due to storage limitations) but let's be
350 * Whether the application is running in fullscreen mode.
351 * @return {boolean} Whether the application is running in fullscreen mode.
354 google.bookmarkbubble.Bubble.prototype.isFullscreen_ = function() {
355 return !!window.navigator.standalone;
360 * Whether the application is running inside Mobile Safari.
361 * @return {boolean} True if the current user agent looks like Mobile Safari.
364 google.bookmarkbubble.Bubble.prototype.isMobileSafari_ = function() {
365 return this.MOBILE_SAFARI_USERAGENT_REGEX_.test(window.navigator.userAgent);
370 * Whether the application is running on an iPad.
371 * @return {boolean} True if the current user agent looks like an iPad.
374 google.bookmarkbubble.Bubble.prototype.isIpad_ = function() {
375 return this.IPAD_USERAGENT_REGEX_.test(window.navigator.userAgent);
381 * Positions the bubble at the bottom of the viewport using an animated
384 google.bookmarkbubble.Bubble.prototype.setPosition = function() {
385 this.element_.style.WebkitTransition = '-webkit-transform 0.7s ease-out';
386 this.element_.style.WebkitTransform =
387 'translateY(' + this.getVisibleYPosition_() + 'px)';
392 * Destroys the bubble by removing its DOM nodes from the document, and
393 * remembers that it was dismissed.
396 google.bookmarkbubble.Bubble.prototype.closeClickHandler_ = function() {
398 this.rememberDismissal_();
403 * Gets called after a while if the user ignores the bubble.
406 google.bookmarkbubble.Bubble.prototype.autoDestruct_ = function() {
407 if (this.hasBeenDestroyed_) {
410 this.element_.style.WebkitTransition = '-webkit-transform 0.7s ease-in';
411 this.element_.style.WebkitTransform =
412 'translateY(' + this.getHiddenYPosition_() + 'px)';
413 window.setTimeout(google.bind(this.destroy, this), 700);
418 * Gets the y offset used to show the bubble (i.e., position it on-screen).
419 * @return {number} The y offset.
422 google.bookmarkbubble.Bubble.prototype.getVisibleYPosition_ = function() {
423 return this.isIpad_() ? window.pageYOffset + 17 :
424 window.pageYOffset - this.element_.offsetHeight + window.innerHeight - 17;
429 * Gets the y offset used to hide the bubble (i.e., position it off-screen).
430 * @return {number} The y offset.
433 google.bookmarkbubble.Bubble.prototype.getHiddenYPosition_ = function() {
434 return this.isIpad_() ? window.pageYOffset - this.element_.offsetHeight :
435 window.pageYOffset + window.innerHeight;
440 * The url of the app's bookmark icon.
441 * @type {string|undefined}
444 google.bookmarkbubble.Bubble.prototype.iconUrl_;
448 * Scrapes the document for a link element that specifies an Apple favicon and
449 * returns the icon url. Returns an empty data url if nothing can be found.
450 * @return {string} A url string.
453 google.bookmarkbubble.Bubble.prototype.getIconUrl_ = function() {
454 if (!this.iconUrl_) {
455 var link = this.getLink(this.REL_ICON_);
456 if (!link || !(this.iconUrl_ = link.href)) {
457 this.iconUrl_ = 'data:image/png;base64,';
460 return this.iconUrl_;
465 * Gets the requested link tag if it exists.
466 * @param {string} rel The rel attribute of the link tag to get.
467 * @return {Element} The requested link tag or null.
469 google.bookmarkbubble.Bubble.prototype.getLink = function(rel) {
470 rel = rel.toLowerCase();
471 var links = document.getElementsByTagName('link');
472 for (var i = 0; i < links.length; ++i) {
473 var currLink = /** @type {Element} */ (links[i]);
474 if (currLink.getAttribute('rel').toLowerCase() == rel) {
483 * Creates the bubble and appends it to the document.
484 * @return {Element} The bubble element.
487 google.bookmarkbubble.Bubble.prototype.build_ = function() {
488 var bubble = document.createElement('div');
489 var isIpad = this.isIpad_();
491 bubble.style.position = 'absolute';
492 bubble.style.zIndex = 1000;
493 bubble.style.width = '100%';
494 bubble.style.left = '0';
495 bubble.style.top = '0';
497 var bubbleInner = document.createElement('div');
498 bubbleInner.style.position = 'relative';
499 bubbleInner.style.width = '214px';
500 bubbleInner.style.margin = isIpad ? '0 0 0 82px' : '0 auto';
501 bubbleInner.style.border = '2px solid #fff';
502 bubbleInner.style.padding = '20px 20px 20px 10px';
503 bubbleInner.style.WebkitBorderRadius = '8px';
504 bubbleInner.style.WebkitBoxShadow = '0 0 8px rgba(0, 0, 0, 0.7)';
505 bubbleInner.style.WebkitBackgroundSize = '100% 8px';
506 bubbleInner.style.backgroundColor = '#b0c8ec';
507 bubbleInner.style.background = '#cddcf3 -webkit-gradient(linear, ' +
508 'left bottom, left top, ' + isIpad ?
509 'from(#cddcf3), to(#b3caed)) no-repeat top' :
510 'from(#b3caed), to(#cddcf3)) no-repeat bottom';
511 bubbleInner.style.font = '13px/17px sans-serif';
512 bubble.appendChild(bubbleInner);
514 // The "Add to Home Screen" text is intended to be the exact same size text
515 // that is displayed in the menu of Mobile Safari on iPhone.
516 bubbleInner.innerHTML = 'Install this web app on your phone: tap ' +
517 '<b style="font-size:15px">+</b> and then <b>\'Add to Home Screen\'</b>';
519 var icon = document.createElement('div');
520 icon.style['float'] = 'left';
521 icon.style.width = '55px';
522 icon.style.height = '55px';
523 icon.style.margin = '-2px 7px 3px 5px';
524 icon.style.background =
525 '#fff url(' + this.getIconUrl_() + ') no-repeat -1px -1px';
526 icon.style.WebkitBackgroundSize = '57px';
527 icon.style.WebkitBorderRadius = '10px';
528 icon.style.WebkitBoxShadow = '0 2px 5px rgba(0, 0, 0, 0.4)';
529 bubbleInner.insertBefore(icon, bubbleInner.firstChild);
531 var arrow = document.createElement('div');
532 arrow.style.backgroundImage = 'url(' + this.IMAGE_ARROW_DATA_URL_ + ')';
533 arrow.style.width = '25px';
534 arrow.style.height = '19px';
535 arrow.style.position = 'absolute';
536 arrow.style.left = '111px';
538 arrow.style.WebkitTransform = 'rotate(180deg)';
539 arrow.style.top = '-19px';
541 arrow.style.bottom = '-19px';
543 bubbleInner.appendChild(arrow);
545 var close = document.createElement('a');
546 close.onclick = google.bind(this.closeClickHandler_, this);
547 close.style.position = 'absolute';
548 close.style.display = 'block';
549 close.style.top = '-3px';
550 close.style.right = '-3px';
551 close.style.width = '16px';
552 close.style.height = '16px';
553 close.style.border = '10px solid transparent';
554 close.style.background =
555 'url(' + this.IMAGE_CLOSE_DATA_URL_ + ') no-repeat';
556 bubbleInner.appendChild(close);