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