Javascript  |  663行  |  20.22 KB

// Copyright (c) 2010 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Dependencies that we should remove/formalize:
// util.js
//
// afterTransition
// chrome.send
// hideNotification
// isRtl
// localStrings
// logEvent
// showNotification


var MostVisited = (function() {

  function addPinnedUrl(item, index) {
    chrome.send('addPinnedURL', [item.url, item.title, item.faviconUrl || '',
                                 item.thumbnailUrl || '', String(index)]);
  }

  function getItem(el) {
    return findAncestorByClass(el, 'thumbnail-container');
  }

  function updatePinnedDom(el, pinned) {
    el.querySelector('.pin').title = localStrings.getString(pinned ?
        'unpinthumbnailtooltip' : 'pinthumbnailtooltip');
    if (pinned) {
      el.classList.add('pinned');
    } else {
      el.classList.remove('pinned');
    }
  }

  function getThumbnailIndex(el) {
    var nodes = el.parentNode.querySelectorAll('.thumbnail-container');
    return Array.prototype.indexOf.call(nodes, el);
  }

  function MostVisited(el, miniview, menu, useSmallGrid, visible) {
    this.element = el;
    this.miniview = miniview;
    this.menu = menu;
    this.useSmallGrid_ = useSmallGrid;
    this.visible_ = visible;

    this.createThumbnails_();
    this.applyMostVisitedRects_();

    el.addEventListener('click', this.handleClick_.bind(this));
    el.addEventListener('keydown', this.handleKeyDown_.bind(this));

    document.addEventListener('DOMContentLoaded',
                              this.ensureSmallGridCorrect.bind(this));

    // Commands
    document.addEventListener('command', this.handleCommand_.bind(this));
    document.addEventListener('canExecute', this.handleCanExecute_.bind(this));

    // DND
    el.addEventListener('dragstart', this.handleDragStart_.bind(this));
    el.addEventListener('dragenter', this.handleDragEnter_.bind(this));
    el.addEventListener('dragover', this.handleDragOver_.bind(this));
    el.addEventListener('dragleave', this.handleDragLeave_.bind(this));
    el.addEventListener('drop', this.handleDrop_.bind(this));
    el.addEventListener('dragend', this.handleDragEnd_.bind(this));
    el.addEventListener('drag', this.handleDrag_.bind(this));
    el.addEventListener('mousedown', this.handleMouseDown_.bind(this));
  }

  MostVisited.prototype = {
    togglePinned_: function(el) {
      var index = getThumbnailIndex(el);
      var item = this.data[index];
      item.pinned = !item.pinned;
      if (item.pinned) {
        addPinnedUrl(item, index);
      } else {
        chrome.send('removePinnedURL', [item.url]);
      }
      updatePinnedDom(el, item.pinned);
    },

    swapPosition_: function(source, destination) {
      var nodes = source.parentNode.querySelectorAll('.thumbnail-container');
      var sourceIndex = getThumbnailIndex(source);
      var destinationIndex = getThumbnailIndex(destination);
      swapDomNodes(source, destination);

      var sourceData = this.data[sourceIndex];
      addPinnedUrl(sourceData, destinationIndex);
      sourceData.pinned = true;
      updatePinnedDom(source, true);

      var destinationData = this.data[destinationIndex];
      // Only update the destination if it was pinned before.
      if (destinationData.pinned) {
        addPinnedUrl(destinationData, sourceIndex);
      }
      this.data[destinationIndex] = sourceData;
      this.data[sourceIndex] = destinationData;
    },

    updateSettingsLink: function(hasBlacklistedUrls) {
      if (hasBlacklistedUrls)
        $('most-visited-settings').classList.add('has-blacklist');
      else
        $('most-visited-settings').classList.remove('has-blacklist');
    },

    blacklist: function(el) {
      var self = this;
      var url = el.href;
      chrome.send('blacklistURLFromMostVisited', [url]);

      el.classList.add('hide');

      // Find the old item.
      var oldUrls = {};
      var oldIndex = -1;
      var oldItem;
      var data = this.data;
      for (var i = 0; i < data.length; i++) {
        if (data[i].url == url) {
          oldItem = data[i];
          oldIndex = i;
        }
        oldUrls[data[i].url] = true;
      }

      // Send 'getMostVisitedPages' with a callback since we want to find the
      // new page and add that in the place of the removed page.
      chromeSend('getMostVisited', [], 'mostVisitedPages',
                 function(data, firstRun, hasBlacklistedUrls) {
        // Update settings link.
        self.updateSettingsLink(hasBlacklistedUrls);

        // Find new item.
        var newItem;
        for (var i = 0; i < data.length; i++) {
          if (!(data[i].url in oldUrls)) {
            newItem = data[i];
            break;
          }
        }

        if (!newItem) {
          // If no other page is available to replace the blacklisted item,
          // we need to reorder items s.t. all filler items are in the rightmost
          // indices.
          self.data = data;

        // Replace old item with new item in the most visited data array.
        } else if (oldIndex != -1) {
          var oldData = self.data.concat();
          oldData.splice(oldIndex, 1, newItem);
          self.data = oldData;
          el.classList.add('fade-in');
        }

        // We wrap the title in a <span class=blacklisted-title>. We pass an
        // empty string to the notifier function and use DOM to insert the real
        // string.
        var actionText = localStrings.getString('undothumbnailremove');

        // Show notification and add undo callback function.
        var wasPinned = oldItem.pinned;
        showNotification('', actionText, function() {
          self.removeFromBlackList(url);
          if (wasPinned) {
            addPinnedUrl(oldItem, oldIndex);
          }
          chrome.send('getMostVisited');
        });

        // Now change the DOM.
        var removeText = localStrings.getString('thumbnailremovednotification');
        var notifyMessageEl = document.querySelector('#notification > *');
        notifyMessageEl.textContent = removeText;

        // Focus the undo link.
        var undoLink = document.querySelector(
            '#notification > .link > [tabindex]');
        undoLink.focus();
      });
    },

    removeFromBlackList: function(url) {
      chrome.send('removeURLsFromMostVisitedBlacklist', [url]);
    },

    clearAllBlacklisted: function() {
      chrome.send('clearMostVisitedURLsBlacklist', []);
      hideNotification();
    },

    dirty_: false,
    invalidate_: function() {
      this.dirty_ = true;
    },

    visible_: true,
    get visible() {
      return this.visible_;
    },
    set visible(visible) {
      if (this.visible_ != visible) {
        this.visible_ = visible;
        this.invalidate_();
      }
    },

    useSmallGrid_: false,
    get useSmallGrid() {
      return this.useSmallGrid_;
    },
    set useSmallGrid(b) {
      if (this.useSmallGrid_ != b) {
        this.useSmallGrid_ = b;
        this.invalidate_();
      }
    },

    layout: function() {
      if (!this.dirty_)
        return;
      var d0 = Date.now();
      this.applyMostVisitedRects_();
      this.dirty_ = false;
      logEvent('mostVisited.layout: ' + (Date.now() - d0));
    },

    createThumbnails_: function() {
      var singleHtml =
          '<a class="thumbnail-container filler" tabindex="1">' +
            '<div class="edit-mode-border">' +
              '<div class="edit-bar">' +
                '<div class="pin"></div>' +
                '<div class="spacer"></div>' +
                '<div class="remove"></div>' +
              '</div>' +
              '<span class="thumbnail-wrapper">' +
                '<span class="thumbnail"></span>' +
              '</span>' +
            '</div>' +
            '<div class="title">' +
              '<div></div>' +
            '</div>' +
          '</a>';
      this.element.innerHTML = Array(8 + 1).join(singleHtml);
      var children = this.element.children;
      for (var i = 0; i < 8; i++) {
        children[i].id = 't' + i;
      }
    },

    getMostVisitedLayoutRects_: function() {
      var small = this.useSmallGrid;

      var cols = 4;
      var rows = 2;
      var marginWidth = 10;
      var marginHeight = 7;
      var borderWidth = 4;
      var thumbWidth = small ? 150 : 207;
      var thumbHeight = small ? 93 : 129;
      var w = thumbWidth + 2 * borderWidth + 2 * marginWidth;
      var h = thumbHeight + 40 + 2 * marginHeight;
      var sumWidth = cols * w  - 2 * marginWidth;
      var topSpacing = 10;

      var rtl = isRtl();
      var rects = [];

      if (this.visible) {
        for (var i = 0; i < rows * cols; i++) {
          var row = Math.floor(i / cols);
          var col = i % cols;
          var left = rtl ? sumWidth - col * w - thumbWidth - 2 * borderWidth :
              col * w;

          var top = row * h + topSpacing;

          rects[i] = {left: left, top: top};
        }
      }
      return rects;
    },

    applyMostVisitedRects_: function() {
      if (this.visible) {
        var rects = this.getMostVisitedLayoutRects_();
        var children = this.element.children;
        for (var i = 0; i < 8; i++) {
          var t = children[i];
          t.style.left = rects[i].left + 'px';
          t.style.top = rects[i].top + 'px';
          t.style.right = '';
          var innerStyle = t.firstElementChild.style;
          innerStyle.left = innerStyle.top = '';
        }
      }
    },

    // Work around for http://crbug.com/25329
    ensureSmallGridCorrect: function(expected) {
      if (expected != this.useSmallGrid)
        this.applyMostVisitedRects_();
    },

    getRectByIndex_: function(index) {
      return this.getMostVisitedLayoutRects_()[index];
    },

    // Commands

    handleCommand_: function(e) {
      var commandId = e.command.id;
      switch (commandId) {
        case 'clear-all-blacklisted':
          this.clearAllBlacklisted();
          chrome.send('getMostVisited');
          break;
      }
    },

    handleCanExecute_: function(e) {
      if (e.command.id == 'clear-all-blacklisted')
        e.canExecute = true;
    },

    // DND

    currentOverItem_: null,
    get currentOverItem() {
      return this.currentOverItem_;
    },
    set currentOverItem(item) {
      var style;
      if (item != this.currentOverItem_) {
        if (this.currentOverItem_) {
          style = this.currentOverItem_.firstElementChild.style;
          style.left = style.top = '';
        }
        this.currentOverItem_ = item;

        if (item) {
          // Make the drag over item move 15px towards the source. The movement
          // is done by only moving the edit-mode-border (as in the mocks) and
          // it is done with relative positioning so that the movement does not
          // change the drop target.
          var dragIndex = getThumbnailIndex(this.dragItem_);
          var overIndex = getThumbnailIndex(item);
          if (dragIndex == -1 || overIndex == -1) {
            return;
          }

          var dragRect = this.getRectByIndex_(dragIndex);
          var overRect = this.getRectByIndex_(overIndex);

          var x = dragRect.left - overRect.left;
          var y = dragRect.top - overRect.top;
          var z = Math.sqrt(x * x + y * y);
          var z2 = 15;
          var x2 = x * z2 / z;
          var y2 = y * z2 / z;

          style = this.currentOverItem_.firstElementChild.style;
          style.left = x2 + 'px';
          style.top = y2 + 'px';
        }
      }
    },
    dragItem_: null,
    startX_: 0,
    startY_: 0,
    startScreenX_: 0,
    startScreenY_: 0,
    dragEndTimer_: null,

    isDragging: function() {
      return !!this.dragItem_;
    },

    handleDragStart_: function(e) {
      var thumbnail = getItem(e.target);
      if (thumbnail) {
        // Don't set data since HTML5 does not allow setting the name for
        // url-list. Instead, we just rely on the dragging of link behavior.
        this.dragItem_ = thumbnail;
        this.dragItem_.classList.add('dragging');
        this.dragItem_.style.zIndex = 2;
        e.dataTransfer.effectAllowed = 'copyLinkMove';
      }
    },

    handleDragEnter_: function(e) {
      if (this.canDropOnElement_(this.currentOverItem)) {
        e.preventDefault();
      }
    },

    handleDragOver_: function(e) {
      var item = getItem(e.target);
      this.currentOverItem = item;
      if (this.canDropOnElement_(item)) {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
      }
    },

    handleDragLeave_: function(e) {
      var item = getItem(e.target);
      if (item) {
        e.preventDefault();
      }

      this.currentOverItem = null;
    },

    handleDrop_: function(e) {
      var dropTarget = getItem(e.target);
      if (this.canDropOnElement_(dropTarget)) {
        dropTarget.style.zIndex = 1;
        this.swapPosition_(this.dragItem_, dropTarget);
        // The timeout below is to allow WebKit to see that we turned off
        // pointer-event before moving the thumbnails so that we can get out of
        // hover mode.
        window.setTimeout((function() {
          this.invalidate_();
          this.layout();
        }).bind(this), 10);
        e.preventDefault();
        if (this.dragEndTimer_) {
          window.clearTimeout(this.dragEndTimer_);
          this.dragEndTimer_ = null;
        }
        afterTransition(function() {
          dropTarget.style.zIndex = '';
        });
      }
    },

    handleDragEnd_: function(e) {
      var dragItem = this.dragItem_;
      if (dragItem) {
        dragItem.style.pointerEvents = '';
        dragItem.classList.remove('dragging');

        afterTransition(function() {
          // Delay resetting zIndex to let the animation finish.
          dragItem.style.zIndex = '';
          // Same for overflow.
          dragItem.parentNode.style.overflow = '';
        });

        this.invalidate_();
        this.layout();
        this.dragItem_ = null;
      }
    },

    handleDrag_: function(e) {
      // Moves the drag item making sure that it is not displayed outside the
      // browser viewport.
      var item = getItem(e.target);
      var rect = this.element.getBoundingClientRect();
      item.style.pointerEvents = 'none';

      var x = this.startX_ + e.screenX - this.startScreenX_;
      var y = this.startY_ + e.screenY - this.startScreenY_;

      // The position of the item is relative to #most-visited so we need to
      // subtract that when calculating the allowed position.
      x = Math.max(x, -rect.left);
      x = Math.min(x, document.body.clientWidth - rect.left - item.offsetWidth -
                   2);
      // The shadow is 2px
      y = Math.max(-rect.top, y);
      y = Math.min(y, document.body.clientHeight - rect.top -
                   item.offsetHeight - 2);

      // Override right in case of RTL.
      item.style.right = 'auto';
      item.style.left = x + 'px';
      item.style.top = y + 'px';
      item.style.zIndex = 2;
    },

    // We listen to mousedown to get the relative position of the cursor for
    // dnd.
    handleMouseDown_: function(e) {
      var item = getItem(e.target);
      if (item) {
        this.startX_ = item.offsetLeft;
        this.startY_ = item.offsetTop;
        this.startScreenX_ = e.screenX;
        this.startScreenY_ = e.screenY;

        // We don't want to focus the item on mousedown. However, to prevent
        // focus one has to call preventDefault but this also prevents the drag
        // and drop (sigh) so we only prevent it when the user is not doing a
        // left mouse button drag.
        if (e.button != 0) // LEFT
          e.preventDefault();
      }
    },

    canDropOnElement_: function(el) {
      return this.dragItem_ && el &&
          el.classList.contains('thumbnail-container') &&
          !el.classList.contains('filler');
    },


    /// data

    data_: null,
    get data() {
      return this.data_;
    },
    set data(data) {
      // We append the class name with the "filler" so that we can style fillers
      // differently.
      var maxItems = 8;
      data.length = Math.min(maxItems, data.length);
      var len = data.length;
      for (var i = len; i < maxItems; i++) {
        data[i] = {filler: true};
      }

      // On setting we need to update the items
      this.data_ = data;
      this.updateMostVisited_();
      this.updateMiniview_();
      this.updateMenu_();
    },

    updateMostVisited_: function() {

      function getThumbnailClassName(item) {
        return 'thumbnail-container' +
            (item.pinned ? ' pinned' : '') +
            (item.filler ? ' filler' : '');
      }

      var data = this.data;
      var children = this.element.children;
      for (var i = 0; i < data.length; i++) {
        var d = data[i];
        var t = children[i];

        // If we have a filler continue
        var oldClassName = t.className;
        var newClassName = getThumbnailClassName(d);
        if (oldClassName != newClassName) {
          t.className = newClassName;
        }

        // No need to continue if this is a filler.
        if (newClassName == 'thumbnail-container filler') {
          // Make sure the user cannot tab to the filler.
          t.tabIndex = -1;
          t.querySelector('.thumbnail-wrapper').style.backgroundImage = '';
          continue;
        }
        // Allow focus.
        t.tabIndex = 1;

        t.href = d.url;
        t.setAttribute('ping',
            getAppPingUrl('PING_BY_URL', d.url, 'NTP_MOST_VISITED'));
        t.querySelector('.pin').title = localStrings.getString(d.pinned ?
            'unpinthumbnailtooltip' : 'pinthumbnailtooltip');
        t.querySelector('.remove').title =
            localStrings.getString('removethumbnailtooltip');

        // There was some concern that a malformed malicious URL could cause an
        // XSS attack but setting style.backgroundImage = 'url(javascript:...)'
        // does not execute the JavaScript in WebKit.

        var thumbnailUrl = d.thumbnailUrl || 'chrome://thumb/' + d.url;
        t.querySelector('.thumbnail-wrapper').style.backgroundImage =
            url(thumbnailUrl);
        var titleDiv = t.querySelector('.title > div');
        titleDiv.xtitle = titleDiv.textContent = d.title;
        var faviconUrl = d.faviconUrl || 'chrome://favicon/' + d.url;
        titleDiv.style.backgroundImage = url(faviconUrl);
        titleDiv.dir = d.direction;
      }
    },

    updateMiniview_: function() {
      this.miniview.textContent = '';
      var data = this.data.slice(0, MAX_MINIVIEW_ITEMS);
      for (var i = 0, item; item = data[i]; i++) {
        if (item.filler) {
          continue;
        }

        var span = document.createElement('span');
        var a = span.appendChild(document.createElement('a'));
        a.href = item.url;
        a.setAttribute('ping',
            getAppPingUrl('PING_BY_URL', item.url, 'NTP_MOST_VISITED'));
        a.textContent = item.title;
        a.style.backgroundImage = url('chrome://favicon/' + item.url);
        a.className = 'item';
        this.miniview.appendChild(span);
      }
      updateMiniviewClipping(this.miniview);
    },

    updateMenu_: function() {
      clearClosedMenu(this.menu);
      var data = this.data.slice(0, MAX_MINIVIEW_ITEMS);
      for (var i = 0, item; item = data[i]; i++) {
        if (!item.filler) {
          addClosedMenuEntry(
              this.menu, item.url, item.title, 'chrome://favicon/' + item.url,
              getAppPingUrl('PING_BY_URL', item.url, 'NTP_MOST_VISITED'));
        }
      }
      addClosedMenuFooter(
          this.menu, 'most-visited', MENU_THUMB, Section.THUMB);
    },

    handleClick_: function(e) {
      var target = e.target;
      if (target.classList.contains('pin')) {
        this.togglePinned_(getItem(target));
        e.preventDefault();
      } else if (target.classList.contains('remove')) {
        this.blacklist(getItem(target));
        e.preventDefault();
      } else {
        var item = getItem(target);
        if (item) {
          var index = Array.prototype.indexOf.call(item.parentNode.children,
                                                   item);
          if (index != -1)
            chrome.send('metrics', ['NTP_MostVisited' + index]);
        }
      }
    },

    /**
     * Allow blacklisting most visited site using the keyboard.
     */
    handleKeyDown_: function(e) {
      if (!IS_MAC && e.keyCode == 46 || // Del
          IS_MAC && e.metaKey && e.keyCode == 8) { // Cmd + Backspace
        this.blacklist(e.target);
      }
    }
  };

  return MostVisited;
})();