// 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; })();