Home | History | Annotate | Download | only in ntp
      1 // Copyright (c) 2010 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 // Dependencies that we should remove/formalize:
      6 // util.js
      7 //
      8 // afterTransition
      9 // chrome.send
     10 // hideNotification
     11 // isRtl
     12 // localStrings
     13 // logEvent
     14 // showNotification
     15 
     16 
     17 var MostVisited = (function() {
     18 
     19   function addPinnedUrl(item, index) {
     20     chrome.send('addPinnedURL', [item.url, item.title, item.faviconUrl || '',
     21                                  item.thumbnailUrl || '', String(index)]);
     22   }
     23 
     24   function getItem(el) {
     25     return findAncestorByClass(el, 'thumbnail-container');
     26   }
     27 
     28   function updatePinnedDom(el, pinned) {
     29     el.querySelector('.pin').title = localStrings.getString(pinned ?
     30         'unpinthumbnailtooltip' : 'pinthumbnailtooltip');
     31     if (pinned) {
     32       el.classList.add('pinned');
     33     } else {
     34       el.classList.remove('pinned');
     35     }
     36   }
     37 
     38   function getThumbnailIndex(el) {
     39     var nodes = el.parentNode.querySelectorAll('.thumbnail-container');
     40     return Array.prototype.indexOf.call(nodes, el);
     41   }
     42 
     43   function MostVisited(el, miniview, menu, useSmallGrid, visible) {
     44     this.element = el;
     45     this.miniview = miniview;
     46     this.menu = menu;
     47     this.useSmallGrid_ = useSmallGrid;
     48     this.visible_ = visible;
     49 
     50     this.createThumbnails_();
     51     this.applyMostVisitedRects_();
     52 
     53     el.addEventListener('click', this.handleClick_.bind(this));
     54     el.addEventListener('keydown', this.handleKeyDown_.bind(this));
     55 
     56     document.addEventListener('DOMContentLoaded',
     57                               this.ensureSmallGridCorrect.bind(this));
     58 
     59     // Commands
     60     document.addEventListener('command', this.handleCommand_.bind(this));
     61     document.addEventListener('canExecute', this.handleCanExecute_.bind(this));
     62 
     63     // DND
     64     el.addEventListener('dragstart', this.handleDragStart_.bind(this));
     65     el.addEventListener('dragenter', this.handleDragEnter_.bind(this));
     66     el.addEventListener('dragover', this.handleDragOver_.bind(this));
     67     el.addEventListener('dragleave', this.handleDragLeave_.bind(this));
     68     el.addEventListener('drop', this.handleDrop_.bind(this));
     69     el.addEventListener('dragend', this.handleDragEnd_.bind(this));
     70     el.addEventListener('drag', this.handleDrag_.bind(this));
     71     el.addEventListener('mousedown', this.handleMouseDown_.bind(this));
     72   }
     73 
     74   MostVisited.prototype = {
     75     togglePinned_: function(el) {
     76       var index = getThumbnailIndex(el);
     77       var item = this.data[index];
     78       item.pinned = !item.pinned;
     79       if (item.pinned) {
     80         addPinnedUrl(item, index);
     81       } else {
     82         chrome.send('removePinnedURL', [item.url]);
     83       }
     84       updatePinnedDom(el, item.pinned);
     85     },
     86 
     87     swapPosition_: function(source, destination) {
     88       var nodes = source.parentNode.querySelectorAll('.thumbnail-container');
     89       var sourceIndex = getThumbnailIndex(source);
     90       var destinationIndex = getThumbnailIndex(destination);
     91       swapDomNodes(source, destination);
     92 
     93       var sourceData = this.data[sourceIndex];
     94       addPinnedUrl(sourceData, destinationIndex);
     95       sourceData.pinned = true;
     96       updatePinnedDom(source, true);
     97 
     98       var destinationData = this.data[destinationIndex];
     99       // Only update the destination if it was pinned before.
    100       if (destinationData.pinned) {
    101         addPinnedUrl(destinationData, sourceIndex);
    102       }
    103       this.data[destinationIndex] = sourceData;
    104       this.data[sourceIndex] = destinationData;
    105     },
    106 
    107     updateSettingsLink: function(hasBlacklistedUrls) {
    108       if (hasBlacklistedUrls)
    109         $('most-visited-settings').classList.add('has-blacklist');
    110       else
    111         $('most-visited-settings').classList.remove('has-blacklist');
    112     },
    113 
    114     blacklist: function(el) {
    115       var self = this;
    116       var url = el.href;
    117       chrome.send('blacklistURLFromMostVisited', [url]);
    118 
    119       el.classList.add('hide');
    120 
    121       // Find the old item.
    122       var oldUrls = {};
    123       var oldIndex = -1;
    124       var oldItem;
    125       var data = this.data;
    126       for (var i = 0; i < data.length; i++) {
    127         if (data[i].url == url) {
    128           oldItem = data[i];
    129           oldIndex = i;
    130         }
    131         oldUrls[data[i].url] = true;
    132       }
    133 
    134       // Send 'getMostVisitedPages' with a callback since we want to find the
    135       // new page and add that in the place of the removed page.
    136       chromeSend('getMostVisited', [], 'mostVisitedPages',
    137                  function(data, firstRun, hasBlacklistedUrls) {
    138         // Update settings link.
    139         self.updateSettingsLink(hasBlacklistedUrls);
    140 
    141         // Find new item.
    142         var newItem;
    143         for (var i = 0; i < data.length; i++) {
    144           if (!(data[i].url in oldUrls)) {
    145             newItem = data[i];
    146             break;
    147           }
    148         }
    149 
    150         if (!newItem) {
    151           // If no other page is available to replace the blacklisted item,
    152           // we need to reorder items s.t. all filler items are in the rightmost
    153           // indices.
    154           self.data = data;
    155 
    156         // Replace old item with new item in the most visited data array.
    157         } else if (oldIndex != -1) {
    158           var oldData = self.data.concat();
    159           oldData.splice(oldIndex, 1, newItem);
    160           self.data = oldData;
    161           el.classList.add('fade-in');
    162         }
    163 
    164         // We wrap the title in a <span class=blacklisted-title>. We pass an
    165         // empty string to the notifier function and use DOM to insert the real
    166         // string.
    167         var actionText = localStrings.getString('undothumbnailremove');
    168 
    169         // Show notification and add undo callback function.
    170         var wasPinned = oldItem.pinned;
    171         showNotification('', actionText, function() {
    172           self.removeFromBlackList(url);
    173           if (wasPinned) {
    174             addPinnedUrl(oldItem, oldIndex);
    175           }
    176           chrome.send('getMostVisited');
    177         });
    178 
    179         // Now change the DOM.
    180         var removeText = localStrings.getString('thumbnailremovednotification');
    181         var notifyMessageEl = document.querySelector('#notification > *');
    182         notifyMessageEl.textContent = removeText;
    183 
    184         // Focus the undo link.
    185         var undoLink = document.querySelector(
    186             '#notification > .link > [tabindex]');
    187         undoLink.focus();
    188       });
    189     },
    190 
    191     removeFromBlackList: function(url) {
    192       chrome.send('removeURLsFromMostVisitedBlacklist', [url]);
    193     },
    194 
    195     clearAllBlacklisted: function() {
    196       chrome.send('clearMostVisitedURLsBlacklist', []);
    197       hideNotification();
    198     },
    199 
    200     dirty_: false,
    201     invalidate_: function() {
    202       this.dirty_ = true;
    203     },
    204 
    205     visible_: true,
    206     get visible() {
    207       return this.visible_;
    208     },
    209     set visible(visible) {
    210       if (this.visible_ != visible) {
    211         this.visible_ = visible;
    212         this.invalidate_();
    213       }
    214     },
    215 
    216     useSmallGrid_: false,
    217     get useSmallGrid() {
    218       return this.useSmallGrid_;
    219     },
    220     set useSmallGrid(b) {
    221       if (this.useSmallGrid_ != b) {
    222         this.useSmallGrid_ = b;
    223         this.invalidate_();
    224       }
    225     },
    226 
    227     layout: function() {
    228       if (!this.dirty_)
    229         return;
    230       var d0 = Date.now();
    231       this.applyMostVisitedRects_();
    232       this.dirty_ = false;
    233       logEvent('mostVisited.layout: ' + (Date.now() - d0));
    234     },
    235 
    236     createThumbnails_: function() {
    237       var singleHtml =
    238           '<a class="thumbnail-container filler" tabindex="1">' +
    239             '<div class="edit-mode-border">' +
    240               '<div class="edit-bar">' +
    241                 '<div class="pin"></div>' +
    242                 '<div class="spacer"></div>' +
    243                 '<div class="remove"></div>' +
    244               '</div>' +
    245               '<span class="thumbnail-wrapper">' +
    246                 '<span class="thumbnail"></span>' +
    247               '</span>' +
    248             '</div>' +
    249             '<div class="title">' +
    250               '<div></div>' +
    251             '</div>' +
    252           '</a>';
    253       this.element.innerHTML = Array(8 + 1).join(singleHtml);
    254       var children = this.element.children;
    255       for (var i = 0; i < 8; i++) {
    256         children[i].id = 't' + i;
    257       }
    258     },
    259 
    260     getMostVisitedLayoutRects_: function() {
    261       var small = this.useSmallGrid;
    262 
    263       var cols = 4;
    264       var rows = 2;
    265       var marginWidth = 10;
    266       var marginHeight = 7;
    267       var borderWidth = 4;
    268       var thumbWidth = small ? 150 : 207;
    269       var thumbHeight = small ? 93 : 129;
    270       var w = thumbWidth + 2 * borderWidth + 2 * marginWidth;
    271       var h = thumbHeight + 40 + 2 * marginHeight;
    272       var sumWidth = cols * w  - 2 * marginWidth;
    273       var topSpacing = 10;
    274 
    275       var rtl = isRtl();
    276       var rects = [];
    277 
    278       if (this.visible) {
    279         for (var i = 0; i < rows * cols; i++) {
    280           var row = Math.floor(i / cols);
    281           var col = i % cols;
    282           var left = rtl ? sumWidth - col * w - thumbWidth - 2 * borderWidth :
    283               col * w;
    284 
    285           var top = row * h + topSpacing;
    286 
    287           rects[i] = {left: left, top: top};
    288         }
    289       }
    290       return rects;
    291     },
    292 
    293     applyMostVisitedRects_: function() {
    294       if (this.visible) {
    295         var rects = this.getMostVisitedLayoutRects_();
    296         var children = this.element.children;
    297         for (var i = 0; i < 8; i++) {
    298           var t = children[i];
    299           t.style.left = rects[i].left + 'px';
    300           t.style.top = rects[i].top + 'px';
    301           t.style.right = '';
    302           var innerStyle = t.firstElementChild.style;
    303           innerStyle.left = innerStyle.top = '';
    304         }
    305       }
    306     },
    307 
    308     // Work around for http://crbug.com/25329
    309     ensureSmallGridCorrect: function(expected) {
    310       if (expected != this.useSmallGrid)
    311         this.applyMostVisitedRects_();
    312     },
    313 
    314     getRectByIndex_: function(index) {
    315       return this.getMostVisitedLayoutRects_()[index];
    316     },
    317 
    318     // Commands
    319 
    320     handleCommand_: function(e) {
    321       var commandId = e.command.id;
    322       switch (commandId) {
    323         case 'clear-all-blacklisted':
    324           this.clearAllBlacklisted();
    325           chrome.send('getMostVisited');
    326           break;
    327       }
    328     },
    329 
    330     handleCanExecute_: function(e) {
    331       if (e.command.id == 'clear-all-blacklisted')
    332         e.canExecute = true;
    333     },
    334 
    335     // DND
    336 
    337     currentOverItem_: null,
    338     get currentOverItem() {
    339       return this.currentOverItem_;
    340     },
    341     set currentOverItem(item) {
    342       var style;
    343       if (item != this.currentOverItem_) {
    344         if (this.currentOverItem_) {
    345           style = this.currentOverItem_.firstElementChild.style;
    346           style.left = style.top = '';
    347         }
    348         this.currentOverItem_ = item;
    349 
    350         if (item) {
    351           // Make the drag over item move 15px towards the source. The movement
    352           // is done by only moving the edit-mode-border (as in the mocks) and
    353           // it is done with relative positioning so that the movement does not
    354           // change the drop target.
    355           var dragIndex = getThumbnailIndex(this.dragItem_);
    356           var overIndex = getThumbnailIndex(item);
    357           if (dragIndex == -1 || overIndex == -1) {
    358             return;
    359           }
    360 
    361           var dragRect = this.getRectByIndex_(dragIndex);
    362           var overRect = this.getRectByIndex_(overIndex);
    363 
    364           var x = dragRect.left - overRect.left;
    365           var y = dragRect.top - overRect.top;
    366           var z = Math.sqrt(x * x + y * y);
    367           var z2 = 15;
    368           var x2 = x * z2 / z;
    369           var y2 = y * z2 / z;
    370 
    371           style = this.currentOverItem_.firstElementChild.style;
    372           style.left = x2 + 'px';
    373           style.top = y2 + 'px';
    374         }
    375       }
    376     },
    377     dragItem_: null,
    378     startX_: 0,
    379     startY_: 0,
    380     startScreenX_: 0,
    381     startScreenY_: 0,
    382     dragEndTimer_: null,
    383 
    384     isDragging: function() {
    385       return !!this.dragItem_;
    386     },
    387 
    388     handleDragStart_: function(e) {
    389       var thumbnail = getItem(e.target);
    390       if (thumbnail) {
    391         // Don't set data since HTML5 does not allow setting the name for
    392         // url-list. Instead, we just rely on the dragging of link behavior.
    393         this.dragItem_ = thumbnail;
    394         this.dragItem_.classList.add('dragging');
    395         this.dragItem_.style.zIndex = 2;
    396         e.dataTransfer.effectAllowed = 'copyLinkMove';
    397       }
    398     },
    399 
    400     handleDragEnter_: function(e) {
    401       if (this.canDropOnElement_(this.currentOverItem)) {
    402         e.preventDefault();
    403       }
    404     },
    405 
    406     handleDragOver_: function(e) {
    407       var item = getItem(e.target);
    408       this.currentOverItem = item;
    409       if (this.canDropOnElement_(item)) {
    410         e.preventDefault();
    411         e.dataTransfer.dropEffect = 'move';
    412       }
    413     },
    414 
    415     handleDragLeave_: function(e) {
    416       var item = getItem(e.target);
    417       if (item) {
    418         e.preventDefault();
    419       }
    420 
    421       this.currentOverItem = null;
    422     },
    423 
    424     handleDrop_: function(e) {
    425       var dropTarget = getItem(e.target);
    426       if (this.canDropOnElement_(dropTarget)) {
    427         dropTarget.style.zIndex = 1;
    428         this.swapPosition_(this.dragItem_, dropTarget);
    429         // The timeout below is to allow WebKit to see that we turned off
    430         // pointer-event before moving the thumbnails so that we can get out of
    431         // hover mode.
    432         window.setTimeout((function() {
    433           this.invalidate_();
    434           this.layout();
    435         }).bind(this), 10);
    436         e.preventDefault();
    437         if (this.dragEndTimer_) {
    438           window.clearTimeout(this.dragEndTimer_);
    439           this.dragEndTimer_ = null;
    440         }
    441         afterTransition(function() {
    442           dropTarget.style.zIndex = '';
    443         });
    444       }
    445     },
    446 
    447     handleDragEnd_: function(e) {
    448       var dragItem = this.dragItem_;
    449       if (dragItem) {
    450         dragItem.style.pointerEvents = '';
    451         dragItem.classList.remove('dragging');
    452 
    453         afterTransition(function() {
    454           // Delay resetting zIndex to let the animation finish.
    455           dragItem.style.zIndex = '';
    456           // Same for overflow.
    457           dragItem.parentNode.style.overflow = '';
    458         });
    459 
    460         this.invalidate_();
    461         this.layout();
    462         this.dragItem_ = null;
    463       }
    464     },
    465 
    466     handleDrag_: function(e) {
    467       // Moves the drag item making sure that it is not displayed outside the
    468       // browser viewport.
    469       var item = getItem(e.target);
    470       var rect = this.element.getBoundingClientRect();
    471       item.style.pointerEvents = 'none';
    472 
    473       var x = this.startX_ + e.screenX - this.startScreenX_;
    474       var y = this.startY_ + e.screenY - this.startScreenY_;
    475 
    476       // The position of the item is relative to #most-visited so we need to
    477       // subtract that when calculating the allowed position.
    478       x = Math.max(x, -rect.left);
    479       x = Math.min(x, document.body.clientWidth - rect.left - item.offsetWidth -
    480                    2);
    481       // The shadow is 2px
    482       y = Math.max(-rect.top, y);
    483       y = Math.min(y, document.body.clientHeight - rect.top -
    484                    item.offsetHeight - 2);
    485 
    486       // Override right in case of RTL.
    487       item.style.right = 'auto';
    488       item.style.left = x + 'px';
    489       item.style.top = y + 'px';
    490       item.style.zIndex = 2;
    491     },
    492 
    493     // We listen to mousedown to get the relative position of the cursor for
    494     // dnd.
    495     handleMouseDown_: function(e) {
    496       var item = getItem(e.target);
    497       if (item) {
    498         this.startX_ = item.offsetLeft;
    499         this.startY_ = item.offsetTop;
    500         this.startScreenX_ = e.screenX;
    501         this.startScreenY_ = e.screenY;
    502 
    503         // We don't want to focus the item on mousedown. However, to prevent
    504         // focus one has to call preventDefault but this also prevents the drag
    505         // and drop (sigh) so we only prevent it when the user is not doing a
    506         // left mouse button drag.
    507         if (e.button != 0) // LEFT
    508           e.preventDefault();
    509       }
    510     },
    511 
    512     canDropOnElement_: function(el) {
    513       return this.dragItem_ && el &&
    514           el.classList.contains('thumbnail-container') &&
    515           !el.classList.contains('filler');
    516     },
    517 
    518 
    519     /// data
    520 
    521     data_: null,
    522     get data() {
    523       return this.data_;
    524     },
    525     set data(data) {
    526       // We append the class name with the "filler" so that we can style fillers
    527       // differently.
    528       var maxItems = 8;
    529       data.length = Math.min(maxItems, data.length);
    530       var len = data.length;
    531       for (var i = len; i < maxItems; i++) {
    532         data[i] = {filler: true};
    533       }
    534 
    535       // On setting we need to update the items
    536       this.data_ = data;
    537       this.updateMostVisited_();
    538       this.updateMiniview_();
    539       this.updateMenu_();
    540     },
    541 
    542     updateMostVisited_: function() {
    543 
    544       function getThumbnailClassName(item) {
    545         return 'thumbnail-container' +
    546             (item.pinned ? ' pinned' : '') +
    547             (item.filler ? ' filler' : '');
    548       }
    549 
    550       var data = this.data;
    551       var children = this.element.children;
    552       for (var i = 0; i < data.length; i++) {
    553         var d = data[i];
    554         var t = children[i];
    555 
    556         // If we have a filler continue
    557         var oldClassName = t.className;
    558         var newClassName = getThumbnailClassName(d);
    559         if (oldClassName != newClassName) {
    560           t.className = newClassName;
    561         }
    562 
    563         // No need to continue if this is a filler.
    564         if (newClassName == 'thumbnail-container filler') {
    565           // Make sure the user cannot tab to the filler.
    566           t.tabIndex = -1;
    567           t.querySelector('.thumbnail-wrapper').style.backgroundImage = '';
    568           continue;
    569         }
    570         // Allow focus.
    571         t.tabIndex = 1;
    572 
    573         t.href = d.url;
    574         t.setAttribute('ping',
    575             getAppPingUrl('PING_BY_URL', d.url, 'NTP_MOST_VISITED'));
    576         t.querySelector('.pin').title = localStrings.getString(d.pinned ?
    577             'unpinthumbnailtooltip' : 'pinthumbnailtooltip');
    578         t.querySelector('.remove').title =
    579             localStrings.getString('removethumbnailtooltip');
    580 
    581         // There was some concern that a malformed malicious URL could cause an
    582         // XSS attack but setting style.backgroundImage = 'url(javascript:...)'
    583         // does not execute the JavaScript in WebKit.
    584 
    585         var thumbnailUrl = d.thumbnailUrl || 'chrome://thumb/' + d.url;
    586         t.querySelector('.thumbnail-wrapper').style.backgroundImage =
    587             url(thumbnailUrl);
    588         var titleDiv = t.querySelector('.title > div');
    589         titleDiv.xtitle = titleDiv.textContent = d.title;
    590         var faviconUrl = d.faviconUrl || 'chrome://favicon/' + d.url;
    591         titleDiv.style.backgroundImage = url(faviconUrl);
    592         titleDiv.dir = d.direction;
    593       }
    594     },
    595 
    596     updateMiniview_: function() {
    597       this.miniview.textContent = '';
    598       var data = this.data.slice(0, MAX_MINIVIEW_ITEMS);
    599       for (var i = 0, item; item = data[i]; i++) {
    600         if (item.filler) {
    601           continue;
    602         }
    603 
    604         var span = document.createElement('span');
    605         var a = span.appendChild(document.createElement('a'));
    606         a.href = item.url;
    607         a.setAttribute('ping',
    608             getAppPingUrl('PING_BY_URL', item.url, 'NTP_MOST_VISITED'));
    609         a.textContent = item.title;
    610         a.style.backgroundImage = url('chrome://favicon/' + item.url);
    611         a.className = 'item';
    612         this.miniview.appendChild(span);
    613       }
    614       updateMiniviewClipping(this.miniview);
    615     },
    616 
    617     updateMenu_: function() {
    618       clearClosedMenu(this.menu);
    619       var data = this.data.slice(0, MAX_MINIVIEW_ITEMS);
    620       for (var i = 0, item; item = data[i]; i++) {
    621         if (!item.filler) {
    622           addClosedMenuEntry(
    623               this.menu, item.url, item.title, 'chrome://favicon/' + item.url,
    624               getAppPingUrl('PING_BY_URL', item.url, 'NTP_MOST_VISITED'));
    625         }
    626       }
    627       addClosedMenuFooter(
    628           this.menu, 'most-visited', MENU_THUMB, Section.THUMB);
    629     },
    630 
    631     handleClick_: function(e) {
    632       var target = e.target;
    633       if (target.classList.contains('pin')) {
    634         this.togglePinned_(getItem(target));
    635         e.preventDefault();
    636       } else if (target.classList.contains('remove')) {
    637         this.blacklist(getItem(target));
    638         e.preventDefault();
    639       } else {
    640         var item = getItem(target);
    641         if (item) {
    642           var index = Array.prototype.indexOf.call(item.parentNode.children,
    643                                                    item);
    644           if (index != -1)
    645             chrome.send('metrics', ['NTP_MostVisited' + index]);
    646         }
    647       }
    648     },
    649 
    650     /**
    651      * Allow blacklisting most visited site using the keyboard.
    652      */
    653     handleKeyDown_: function(e) {
    654       if (!IS_MAC && e.keyCode == 46 || // Del
    655           IS_MAC && e.metaKey && e.keyCode == 8) { // Cmd + Backspace
    656         this.blacklist(e.target);
    657       }
    658     }
    659   };
    660 
    661   return MostVisited;
    662 })();
    663