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
17var 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