blob: d415b2f230a94c7b5c4f449391e9be7805b6ee9d [file] [log] [blame]
/* Copyright 2015 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. */
// Single iframe for NTP tiles.
/**
* Controls rendering the Most Visited iframe.
* @return {Object} A limited interface for testing the iframe.
*/
function MostVisited() {
'use strict';
/**
* Enum for key codes.
* @enum {number}
* @const
*/
const KEYCODES = {
BACKSPACE: 8,
DELETE: 46,
DOWN: 40,
ENTER: 13,
ESC: 27,
LEFT: 37,
RIGHT: 39,
SPACE: 32,
TAB: 9,
UP: 38,
};
/**
* Enum for ids.
* @enum {string}
* @const
*/
const IDS = {
MOST_VISITED: 'most-visited', // Container for all tilesets.
MV_TILES: 'mv-tiles', // Most Visited tiles container.
};
/**
* Enum for classnames.
* @enum {string}
* @const
*/
const CLASSES = {
FAILED_FAVICON: 'failed-favicon', // Applied when the favicon fails to load.
GRID_LAYOUT: 'grid-layout',
// Applied to the grid tile being moved while reordering.
GRID_REORDER: 'grid-reorder',
GRID_TILE: 'grid-tile',
GRID_TILE_CONTAINER: 'grid-tile-container',
HIDE: 'hide', // Applied when the tiles are hidden by the user.
REORDER: 'reorder', // Applied to the tile being moved while reordering.
// Applied while we are reordering. Disables hover styling.
REORDERING: 'reordering',
MAC_CHROMEOS: 'mac-chromeos', // Reduces font weight for MacOS and ChromeOS.
// Material Design classes.
MD_EMPTY_TILE: 'md-empty-tile',
MD_FALLBACK_LETTER: 'md-fallback-letter',
MD_ICON: 'md-icon',
MD_ADD_ICON: 'md-add-icon',
MD_MENU: 'md-menu',
MD_EDIT_MENU: 'md-edit-menu',
MD_TILE: 'md-tile',
MD_TILE_INNER: 'md-tile-inner',
MD_TITLE: 'md-title',
NO_INITIAL_FADE: 'no-initial-fade',
};
/**
* The different types of events that are logged from the NTP. This enum is
* used to transfer information from the NTP JavaScript to the renderer and is
* not used as a UMA enum histogram's logged value.
* Note: Keep in sync with common/ntp_logging_events.h
* @enum {number}
* @const
*/
const LOG_TYPE = {
// All NTP tiles have finished loading (successfully or failing).
NTP_ALL_TILES_LOADED: 11,
// Shortcuts have been customized.
NTP_SHORTCUT_CUSTOMIZED: 39,
// The 'Add shortcut' link was clicked.
NTP_CUSTOMIZE_ADD_SHORTCUT_CLICKED: 44,
// The 'Edit shortcut' link was clicked.
NTP_CUSTOMIZE_EDIT_SHORTCUT_CLICKED: 45,
};
/**
* The different (visual) types that an NTP tile can have.
* Note: Keep in sync with components/ntp_tiles/tile_visual_type.h
* @enum {number}
* @const
*/
const TileVisualType = {
NONE: 0,
ICON_REAL: 1,
ICON_COLOR: 2,
ICON_DEFAULT: 3,
THUMBNAIL: 7,
THUMBNAIL_FAILED: 8,
};
/**
* Timeout delay for the window.onresize event throttle. Set to 15 frame per
* second.
* @const {number}
*/
const RESIZE_TIMEOUT_DELAY = 66;
/**
* Timeout delay in ms before starting the reorder flow.
* @const {number}
*/
const REORDER_TIMEOUT_DELAY = 1000;
/**
* Maximum number of tiles if custom links is enabled.
* @const {number}
*/
const MD_MAX_NUM_CUSTOM_LINK_TILES = 10;
/**
* Maximum number of tiles per row for Material Design.
* @const {number}
*/
const MD_MAX_TILES_PER_ROW = 5;
/**
* Height of a tile for Material Design. Keep in sync with
* most_visited_single.css.
* @const {number}
*/
const MD_TILE_HEIGHT = 128;
/**
* Width of a tile for Material Design. Keep in sync with
* most_visited_single.css.
* @const {number}
*/
const MD_TILE_WIDTH = 112;
/**
* Number of tiles that will always be visible for Material Design. Calculated
* by dividing minimum |--content-width| (see local_ntp.css) by |MD_TILE_WIDTH|
* and multiplying by 2 rows.
* @const {number}
*/
const MD_NUM_TILES_ALWAYS_VISIBLE = 6;
/**
* The origin of this request, i.e. 'https://www.google.TLD' for the remote NTP,
* or 'chrome-search://local-ntp' for the local NTP.
* @const {string}
*/
const DOMAIN_ORIGIN = '{{ORIGIN}}';
/**
* Counter for DOM elements that we are waiting to finish loading. Starts out
* at 1 because initially we're waiting for the "show" message from the parent.
* @type {number}
*/
let loadedCounter = 1;
/**
* DOM element containing the tiles we are going to present next.
* Works as a double-buffer that is shown when we receive a "show" postMessage.
* @type {Element}
*/
let tiles = null;
/**
* Maximum number of MostVisited tiles to show at any time. If the host page
* doesn't send enough tiles and custom links is not enabled, we fill them blank
* tiles. This can be changed depending on what feature is enabled. Set by the
* host page, while 8 is default.
* @type {number}
*/
let maxNumTiles = 8;
/**
* List of parameters passed by query args.
* @type {Object}
*/
let queryArgs = {};
/**
* True if we are currently reordering the tiles.
* @type {boolean}
*/
let reordering = false;
/**
* The tile that is being moved during the reorder flow. Null if we are
* currently not reordering.
* @type {?Element}
*/
let elementToReorder = null;
/**
* True if the custom links feature is enabled, i.e. when this is a Google NTP.
* Set when the iframe is initialized.
* @type {boolean}
*/
let customLinksFeatureEnabled = false;
/**
* True if the grid layout is enabled.
* @type {boolean}
*/
let isGridEnabled = false;
/**
* The current grid of tiles.
* @type {?Grid}
*/
let currGrid = null;
/**
* Called by tests to enable the grid layout.
*/
function enableGridLayoutForTesting() {
isGridEnabled = true;
document.body.classList.add(CLASSES.GRID_LAYOUT);
}
/**
* Additional API for Array. Moves the item at index |from| to index |to|.
* @param {number} from Index of the item to move.
* @param {number} to Index to move the item to.
*/
Array.prototype.move = function(from, to) {
this.splice(to, 0, this.splice(from, 1)[0]);
};
/**
* Class that handles layouts and animations for the tile grid. This includes
* animations for adding, deleting, and reordering.
*/
class Grid {
constructor() {
/** @private {number} */
this.tileHeight_ = 0;
/** @private {number} */
this.tileWidth_ = 0;
/** @private {number} */
this.tilesAlwaysVisible_ = 0;
/**
* The maximum number of tiles per row allowed by the grid parameters.
* @private {number}
*/
this.maxTilesPerRow_ = 0;
/** @private {number} */
this.maxTiles_ = 0;
/** @private {number} */
this.gridWidth_ = 0;
/**
* The maximum number of tiles per row allowed by the window width.
* @private {number}
*/
this.maxTilesPerRowWindow_ = 0;
/** @private {?Element} */
this.container_ = null;
/** @private {?HTMLCollection} */
this.tiles_ = null;
/**
* Array that stores the {x,y} positions of the tile layout.
* @private {?Array<!Object>}
*/
this.position_ = null;
/**
* Stores the current order of the tiles. Index corresponds to grid
* position, while value is the index of the tile in |this.tiles_|.
* @private {?Array<number>}
*/
this.order_ = null;
/** @private {number} The index of the tile we're reordering. */
this.itemToReorder_ = -1;
/** @private {number} The index to move the tile we're reordering to. */
this.newIndexOfItemToReorder_ = -1;
/** @private {boolean} True if the user is currently touching a tile. */
this.touchStarted_ = false;
}
/**
* Sets up the grid for the new tileset in |container|. The old tileset is
* discarded.
* @param {!Element} container The grid container element.
* @param {Object=} params Customizable parameters for the grid. Used in
* testing.
*/
init(container, params = {}) {
this.container_ = container;
this.tileHeight_ = params.tileHeight || MD_TILE_HEIGHT;
this.tileWidth_ = params.tileWidth || MD_TILE_WIDTH;
this.tilesAlwaysVisible_ =
params.tilesAlwaysVisible || MD_NUM_TILES_ALWAYS_VISIBLE;
this.maxTilesPerRow_ = params.maxTilesPerRow || MD_MAX_TILES_PER_ROW;
this.maxTiles_ = params.maxTiles || maxNumTiles;
this.maxTilesPerRowWindow_ = this.getMaxTilesPerRow_();
this.tiles_ =
this.container_.getElementsByClassName(CLASSES.GRID_TILE_CONTAINER);
if (this.tiles_.length > this.maxTiles_) {
throw new Error(
'The number of tiles (' + this.tiles_.length +
') exceeds the maximum (' + this.maxTiles_ + ').');
}
this.position_ = new Array(this.maxTiles_);
this.order_ = new Array(this.maxTiles_);
for (let i = 0; i < this.maxTiles_; i++) {
this.position_[i] = {x: 0, y: 0};
this.order_[i] = i;
}
if (isCustomLinksEnabled() || params.enableReorder) {
// Set up reordering for all tiles except the add shortcut button.
for (let i = 0; i < this.tiles_.length; i++) {
if (this.tiles_[i].getAttribute('add') !== 'true') {
this.setupReorder_(this.tiles_[i], i);
}
}
}
this.updateLayout();
}
/**
* Returns a grid tile wrapper that contains |tile|.
* @param {!Element} tile The tile element.
* @param {number} rid The tile's restricted id.
* @param {boolean} isAddButton True if this is the add shortcut button.
* @return {!Element} A grid tile wrapper.
*/
createGridTile(tile, rid, isAddButton) {
const gridTileContainer = document.createElement('div');
gridTileContainer.className = CLASSES.GRID_TILE_CONTAINER;
gridTileContainer.setAttribute('rid', rid);
gridTileContainer.setAttribute('add', isAddButton);
const gridTile = document.createElement('div');
gridTile.className = CLASSES.GRID_TILE;
gridTile.appendChild(tile);
gridTileContainer.appendChild(gridTile);
return gridTileContainer;
}
/**
* Updates the layout of the tiles. This is called for new tilesets and when
* the window is resized or zoomed. Translates each tile's
* |CLASSES.GRID_TILE_CONTAINER| to the correct position.
*/
updateLayout() {
const tilesPerRow = this.getTilesPerRow_();
this.gridWidth_ = tilesPerRow * this.tileWidth_;
this.container_.style.width = this.gridWidth_ + 'px';
const maxVisibleTiles = tilesPerRow * 2;
let x = 0;
let y = 0;
for (let i = 0; i < this.tiles_.length; i++) {
const tile = this.tiles_[i];
// Reset the offset for row 2.
if (i === tilesPerRow) {
x = this.getRow2Offset_(tilesPerRow);
y = this.tileHeight_;
}
// Update the tile's position.
this.translate_(tile, x, y);
this.position_[i].x = x;
this.position_[i].y = y;
x += this.tileWidth_; // Increment for the next tile.
// Update visibility for tiles that may be hidden by the iframe border in
// order to prevent keyboard navigation from reaching them. Ignores tiles
// that will always be visible, since changing 'display' prevents
// transitions from working.
if (i >= this.tilesAlwaysVisible_) {
const isVisible = i < maxVisibleTiles;
tile.style.display = isVisible ? 'block' : 'none';
}
}
}
/**
* Called when the window is resized/zoomed. Recalculates maximums for the new
* window size and calls |updateLayout| if necessary.
*/
onResize() {
// Update the layout if the max number of tiles per row changes due to the
// new window size.
const maxPerRowWindow = this.getMaxTilesPerRow_();
if (maxPerRowWindow !== this.maxTilesPerRowWindow_) {
this.maxTilesPerRowWindow_ = maxPerRowWindow;
this.updateLayout();
}
}
/**
* Returns the number of tiles per row. This may be balanced in order to make
* even rows.
* @return {number} The number of tiles per row.
* @private
*/
getTilesPerRow_() {
const maxTilesPerRow =
Math.min(this.maxTilesPerRow_, this.maxTilesPerRowWindow_);
if (this.tiles_.length >= maxTilesPerRow * 2) {
// We have enough for two full rows, so just return the max.
return maxTilesPerRow;
} else if (this.tiles_.length > maxTilesPerRow) {
// We have have a little more than one full row, so we need to rebalance.
return Math.ceil(this.tiles_.length / 2);
} else {
// We have (less than) a full row, so just return the tiles we have.
return this.tiles_.length;
}
}
/**
* Returns the maximum number of tiles per row allowed by the window size.
* @return {number} The maximum number of tiles per row.
* @private
*/
getMaxTilesPerRow_() {
return Math.floor(window.innerWidth / this.tileWidth_);
}
/**
* Returns row 2's x offset from row 1 in px. This will either be 0 or half a
* tile length.
* @param {number} tilesPerRow The number of tiles per row.
* @return {number} The offset for row 2.
* @private
*/
getRow2Offset_(tilesPerRow) {
// An odd number of tiles requires a half tile offset in the second row,
// unless both rows are full (i.e. for smaller window widths).
if (this.tiles_.length % 2 === 1 && this.tiles_.length / tilesPerRow < 2) {
return Math.round(this.tileWidth_ / 2);
}
return 0;
}
/**
* Returns true if the browser is in RTL.
* @return {boolean}
* @private
*/
isRtl_() {
return document.documentElement.dir === 'rtl';
}
/**
* Translates the |element| by (x, y).
* @param {?Element} element The element to apply the transform to.
* @param {number} x The x value.
* @param {number} y The y value.
* @private
*/
translate_(element, x, y) {
if (!element) {
throw new Error('Invalid element: cannot apply transform');
}
const rtlX = x * (this.isRtl_() ? -1 : 1);
element.style.transform = 'translate(' + rtlX + 'px, ' + y + 'px)';
}
/**
* Sets up event listeners necessary for tile reordering.
* @param {!Element} tile Tile on which to set the event listeners.
* @param {number} index The tile's index.
* @private
*/
setupReorder_(tile, index) {
tile.setAttribute('index', index);
// Set up mouse support.
// Listen for the drag event on the tile instead of the tile container. The
// tile container remains static during the reorder flow.
tile.firstChild.draggable = true;
tile.firstChild.addEventListener('dragstart', (event) => {
this.startReorder_(tile, event, /*mouseMode=*/ true);
});
// Listen for the mouseover event on the tile container. If this is placed
// on the tile instead, it can be triggered while the tile is translated to
// its new position.
tile.addEventListener('mouseover', (event) => {
this.reorderToIndex_(index);
});
// Set up touch support.
tile.firstChild.addEventListener('touchstart', (startEvent) => {
// Ignore subsequent touchstart events, which can be triggered if a
// different finger is placed on this tile.
if (this.touchStarted_) {
return;
}
this.touchStarted_ = true;
// Start the reorder flow once the user moves their finger.
const startReorder = (moveEvent) => {
// Use the cursor position from 'touchstart' as the starting location.
this.startReorder_(tile, startEvent, /*mouseMode=*/ false);
};
// Insert the held tile at the index we are hovering over.
const moveOver = (moveEvent) => {
// Touch events do not have a 'mouseover' equivalent, so we need to
// manually check if we are hovering over a tile.
// Note: The first item in |changedTouches| is the current position.
const x = moveEvent.changedTouches[0].pageX;
const y = moveEvent.changedTouches[0].pageY;
const elements = document.elementsFromPoint(x, y);
for (let i = 0; i < elements.length; i++) {
if (elements[i].classList.contains('grid-tile-container')) {
this.reorderToIndex_(Number(elements[i].getAttribute('index')));
break;
}
}
};
// Allow 'touchstart' events again when reordering stops/was never
// started.
const touchEnd = (endEvent) => {
tile.firstChild.removeEventListener('touchmove', startReorder);
tile.firstChild.removeEventListener('touchmove', moveOver);
tile.firstChild.removeEventListener('touchend', touchEnd);
tile.firstChild.removeEventListener('touchcancel', touchEnd);
this.touchStarted_ = false;
};
tile.firstChild.addEventListener('touchmove', startReorder, {once: true});
tile.firstChild.addEventListener('touchmove', moveOver);
tile.firstChild.addEventListener('touchend', touchEnd, {once: true});
tile.firstChild.addEventListener('touchcancel', touchEnd, {once: true});
});
}
/**
* Starts the reorder flow. Updates the visual style of the held tile to
* indicate that it is being moved and sets up the relevant event listeners.
* @param {!Element} tile Tile that is being moved.
* @param {!Event} event The 'dragstart'/'touchmove' event. Used to obtain the
* current cursor position
* @param {boolean} mouseMode True if the user is using a mouse.
* @private
*/
startReorder_(tile, event, mouseMode) {
const index = Number(tile.getAttribute('index'));
this.itemToReorder_ = index;
this.newIndexOfItemToReorder_ = index;
// Apply reorder styling.
tile.classList.add(CLASSES.GRID_REORDER);
// Disable other hover/active styling for all tiles.
document.body.classList.add(CLASSES.REORDERING);
// Set up event listeners for the reorder flow. Listen for mouse events if
// |mouseMode|, touch events otherwise.
if (mouseMode) {
const mouseMove = this.trackCursor_(tile, event.pageX, event.pageY, true);
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', mouseMove);
this.stopReorder_(tile);
}, {once: true});
} else {
// Track the cursor on subsequent 'touchmove' events (the first
// 'touchmove' event that starts the reorder flow is ignored).
const trackCursor = this.trackCursor_(
tile, event.changedTouches[0].pageX, event.changedTouches[0].pageY,
false);
const touchEnd = (event) => {
tile.firstChild.removeEventListener('touchmove', trackCursor);
tile.firstChild.removeEventListener('touchend', touchEnd);
tile.firstChild.removeEventListener('touchcancel', touchEnd);
this.stopReorder_(tile); // Stop the reorder flow.
};
tile.firstChild.addEventListener('touchmove', trackCursor);
tile.firstChild.addEventListener('touchend', touchEnd, {once: true});
tile.firstChild.addEventListener('touchcancel', touchEnd, {once: true});
}
}
/**
* Stops the reorder flow. Resets the held tile's visual style and tells the
* EmbeddedSearchAPI that a tile has been moved.
* @param {!Element} tile Tile that has been moved.
* @private
*/
stopReorder_(tile) {
const index = Number(tile.getAttribute('index'));
// Remove reorder styling.
tile.classList.remove(CLASSES.GRID_REORDER);
document.body.classList.remove(CLASSES.REORDERING);
// Move the tile to its new position and notify EmbeddedSearchAPI that the
// tile has been moved.
this.applyReorder_(tile, this.newIndexOfItemToReorder_);
chrome.embeddedSearch.newTabPage.reorderCustomLink(
Number(this.tiles_[index].getAttribute('rid')),
this.newIndexOfItemToReorder_);
this.itemToReorder_ = -1;
this.newIndexOfItemToReorder_ = -1;
}
/**
* Executed only when the reorder flow is ongoing. Inserts the currently held
* tile at |index| and shifts tiles accordingly.
* @param {number} index The index to insert the held tile at.
* @private
*/
reorderToIndex_(index) {
if (this.newIndexOfItemToReorder_ === index ||
!document.body.classList.contains(CLASSES.REORDERING)) {
return;
}
// Moves the held tile from its current position to |index|.
this.order_.move(this.newIndexOfItemToReorder_, index);
this.newIndexOfItemToReorder_ = index;
// Shift tiles according to the new order.
for (let i = 0; i < this.tiles_.length; i++) {
const tileIndex = this.order_[i];
// Don't move the tile we're holding nor the add shortcut button.
if (tileIndex === this.itemToReorder_ ||
this.tiles_[i].getAttribute('add') === 'true') {
continue;
}
this.applyReorder_(this.tiles_[tileIndex], i);
}
}
/**
* Translates the |tile|'s |CLASSES.GRID_TILE| from |index| to |newIndex|.
* This is done to prevent interference with event listeners on the |tile|'s
* |CLASSES.GRID_TILE_CONTAINER|, particularly 'mouseover'.
* @param {!Element} tile Tile that is being shifted.
* @param {number} newIndex New index for the tile.
* @private
*/
applyReorder_(tile, newIndex) {
if (tile.getAttribute('index') === null) {
throw new Error('Tile does not have an index.');
}
const index = Number(tile.getAttribute('index'));
const x = this.position_[newIndex].x - this.position_[index].x;
const y = this.position_[newIndex].y - this.position_[index].y;
this.translate_(tile.children[0], x, y);
}
/**
* Moves |tile| so that it tracks the cursor's position. This is done by
* translating the |tile|'s |CLASSES.GRID_TILE|, which prevents interference
* with event listeners on the |tile|'s |CLASSES.GRID_TILE_CONTAINER|.
* @param {!Element} tile Tile that is being moved.
* @param {number} origCursorX Original x cursor position.
* @param {number} origCursorY Original y cursor position.
* @param {boolean} mouseMode True if the user is using a mouse.
* @private
*/
trackCursor_(tile, origCursorX, origCursorY, mouseMode) {
const index = Number(tile.getAttribute('index'));
// RTL positions align with the right side of the grid. Therefore, the x
// value must be recalculated to align with the left.
const origPosX = this.isRtl_() ?
(this.gridWidth_ - (this.position_[index].x + this.tileWidth_)) :
this.position_[index].x;
const origPosY = this.position_[index].y;
// Get the max translation allowed by the grid boundaries. This will be the
// x of the last tile in a row and the y of the tiles in the second row.
const maxTranslateX = this.gridWidth_ - this.tileWidth_;
const maxTranslateY = this.tileHeight_;
const maxX = maxTranslateX - origPosX;
const maxY = maxTranslateY - origPosY;
const minX = 0 - origPosX;
const minY = 0 - origPosY;
return (event) => {
const currX = mouseMode ? event.pageX : event.changedTouches[0].pageX;
const currY = mouseMode ? event.pageY : event.changedTouches[0].pageY;
// Do not exceed the iframe borders.
const x = Math.max(Math.min(currX - origCursorX, maxX), minX);
const y = Math.max(Math.min(currY - origCursorY, maxY), minY);
tile.firstChild.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
};
}
}
/**
* Log an event on the NTP.
* @param {number} eventType Event from LOG_TYPE.
*/
function logEvent(eventType) {
chrome.embeddedSearch.newTabPage.logEvent(eventType);
}
/**
* Log impression of an NTP tile.
* @param {number} tileIndex Position of the tile, >= 0 and < |maxNumTiles|.
* @param {number} tileTitleSource The source of the tile's title as received
* from getMostVisitedItemData.
* @param {number} tileSource The tile's source as received from
* getMostVisitedItemData.
* @param {number} tileType The tile's visual type from TileVisualType.
* @param {Date} dataGenerationTime Timestamp representing when the tile was
* produced by a ranking algorithm.
*/
function logMostVisitedImpression(
tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime) {
chrome.embeddedSearch.newTabPage.logMostVisitedImpression(
tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime);
}
/**
* Log click on an NTP tile.
* @param {number} tileIndex Position of the tile, >= 0 and < |maxNumTiles|.
* @param {number} tileTitleSource The source of the tile's title as received
* from getMostVisitedItemData.
* @param {number} tileSource The tile's source as received from
* getMostVisitedItemData.
* @param {number} tileType The tile's visual type from TileVisualType.
* @param {Date} dataGenerationTime Timestamp representing when the tile was
* produced by a ranking algorithm.
*/
function logMostVisitedNavigation(
tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime) {
chrome.embeddedSearch.newTabPage.logMostVisitedNavigation(
tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime);
}
/**
* Returns true if custom links are enabled.
*/
function isCustomLinksEnabled() {
return customLinksFeatureEnabled &&
!chrome.embeddedSearch.newTabPage.isUsingMostVisited;
}
/**
* Down counts the DOM elements that we are waiting for the page to load.
* When we get to 0, we send a message to the parent window.
* This is usually used as an EventListener of onload/onerror.
*/
function countLoad() {
loadedCounter -= 1;
if (loadedCounter <= 0) {
swapInNewTiles();
logEvent(LOG_TYPE.NTP_ALL_TILES_LOADED);
let tilesAreCustomLinks = isCustomLinksEnabled() &&
chrome.embeddedSearch.newTabPage.isCustomLinks;
// Note that it's easiest to capture this when all custom links are loaded,
// rather than when the impression for each link is logged.
if (tilesAreCustomLinks) {
chrome.embeddedSearch.newTabPage.logEvent(
LOG_TYPE.NTP_SHORTCUT_CUSTOMIZED);
}
// Tell the parent page whether to show the restore default shortcuts option
// in the menu.
window.parent.postMessage(
{cmd: 'loaded', showRestoreDefault: tilesAreCustomLinks},
DOMAIN_ORIGIN);
tilesAreCustomLinks = false;
// Reset to 1, so that any further 'show' message will cause us to swap in
// fresh tiles.
loadedCounter = 1;
}
}
/**
* Handles postMessages coming from the host page to the iframe.
* Mostly, it dispatches every command to handleCommand.
*/
function handlePostMessage(event) {
if (event.data instanceof Array) {
for (let i = 0; i < event.data.length; ++i) {
handleCommand(event.data[i]);
}
} else {
handleCommand(event.data);
}
}
/**
* Handles a single command coming from the host page to the iframe.
* We try to keep the logic here to a minimum and just dispatch to the relevant
* functions.
*/
function handleCommand(data) {
const cmd = data.cmd;
if (cmd == 'tile') {
addTile(data);
} else if (cmd == 'show') {
// TODO(crbug.com/946225): If this happens before we have finished loading
// the previous tiles, we probably get into a bad state. If/when the iframe
// is removed this might no longer be a concern.
showTiles(data);
} else if (cmd == 'updateTheme') {
updateTheme(data);
} else if (cmd === 'focusMenu') {
focusTileMenu(data);
} else {
console.error('Unknown command: ' + JSON.stringify(data));
}
}
/**
* Handler for the 'show' message from the host page.
* @param {!Object} info Data received in the message.
*/
function showTiles(info) {
document.body.classList.toggle(
CLASSES.HIDE, !chrome.embeddedSearch.newTabPage.areShortcutsVisible);
utils.setPlatformClass(document.body);
countLoad();
}
/**
* Handler for the 'updateTheme' message from the host page.
* @param {!Object} info Data received in the message.
*/
function updateTheme(info) {
document.body.style.setProperty('--tile-title-color', info.tileTitleColor);
document.body.style.setProperty(
'--icon-background-color', info.iconBackgroundColor);
document.body.classList.toggle('dark-theme', info.isThemeDark);
document.body.classList.toggle('use-title-container', info.useTitleContainer);
document.body.classList.toggle('custom-background', info.customBackground);
document.body.classList.toggle('use-white-add-icon', info.useWhiteAddIcon);
document.documentElement.setAttribute('darkmode', info.isDarkMode);
// Reduce font weight on the default(white) background for Mac and CrOS.
document.body.classList.toggle(
CLASSES.MAC_CHROMEOS,
!info.isThemeDark && !info.useTitleContainer &&
(navigator.userAgent.indexOf('Mac') > -1 ||
navigator.userAgent.indexOf('CrOS') > -1));
}
/**
* Handler for 'focusMenu' message from the host page. Focuses the edited tile's
* menu or the add shortcut tile after closing the custom link edit dialog
* without saving.
* @param {!Object} info Data received in the message.
*/
function focusTileMenu(info) {
const tile = document.querySelector(`a.md-tile[data-rid="${info.rid}"]`);
if (info.rid === -1 /* Add shortcut tile */) {
tile.focus();
} else {
tile.parentNode.childNodes[1].focus();
}
}
/**
* Removes all old instances of |IDS.MV_TILES| that are pending for deletion.
*/
function removeAllOldTiles() {
const parent = document.querySelector('#' + IDS.MOST_VISITED);
const oldList = parent.querySelectorAll('.mv-tiles-old');
for (let i = 0; i < oldList.length; ++i) {
parent.removeChild(oldList[i]);
}
}
/**
* Called when all tiles have finished loading (successfully or not), including
* their thumbnail images, and we are ready to show the new tiles and drop the
* old ones.
*/
function swapInNewTiles() {
// Store the tiles on the current closure.
const cur = tiles;
// Add an "add new custom link" button if we haven't reached the maximum
// number of tiles.
if (isCustomLinksEnabled() && cur.childNodes.length < maxNumTiles) {
const data = {
'rid': -1,
'title': queryArgs['addLink'],
'url': '',
'isAddButton': true,
'dataGenerationTime': new Date(),
'tileSource': -1,
'tileTitleSource': -1
};
tiles.appendChild(renderMaterialDesignTile(data));
}
const parent = document.querySelector('#' + IDS.MOST_VISITED);
const old = parent.querySelector('#' + IDS.MV_TILES);
if (old) {
// Mark old tile DIV for removal after the transition animation is done.
old.removeAttribute('id');
old.classList.add('mv-tiles-old');
old.style.opacity = 0.0;
cur.addEventListener('transitionend', function(ev) {
if (ev.target === cur) {
removeAllOldTiles();
}
});
}
// Add new tileset.
cur.id = IDS.MV_TILES;
parent.appendChild(cur);
if (isGridEnabled) {
// Initialize the new tileset before modifying opacity. This will prevent
// the transform transition from applying after the tiles fade in.
currGrid.init(cur);
} else {
// Re-balance the tiles if there are more than |MD_MAX_TILES_PER_ROW| in
// order to make even rows.
if (cur.childNodes.length > MD_MAX_TILES_PER_ROW) {
cur.style.maxWidth = 'calc(var(--md-tile-size) * ' +
Math.ceil(cur.childNodes.length / 2) + ')';
}
}
const flushOpacity = () => window.getComputedStyle(cur).opacity;
// getComputedStyle causes the initial style (opacity 0) to be applied, so
// that when we then set it to 1, that triggers the CSS transition.
flushOpacity();
cur.style.opacity = 1.0;
if (document.documentElement.classList.contains(CLASSES.NO_INITIAL_FADE)) {
flushOpacity();
document.documentElement.classList.remove(CLASSES.NO_INITIAL_FADE);
}
// Make sure the tiles variable contain the next tileset we'll use if the host
// page sends us an updated set of tiles.
tiles = document.createElement('div');
}
/**
* Explicitly hide tiles that are not visible in order to prevent keyboard
* navigation.
*/
function updateTileVisibility() {
const allTiles =
document.querySelectorAll('#' + IDS.MV_TILES + ' .' + CLASSES.MD_TILE);
if (allTiles.length === 0) {
return;
}
// Get the current number of tiles per row. Hide any tile after the first two
// rows.
const tilesPerRow = Math.trunc(document.body.offsetWidth / MD_TILE_WIDTH);
for (let i = MD_NUM_TILES_ALWAYS_VISIBLE; i < allTiles.length; i++) {
allTiles[i].style.display = (i < tilesPerRow * 2) ? 'block' : 'none';
}
}
/**
* Handler for the 'show' message from the host page, called when it wants to
* add a suggestion tile.
* It's also used to fill up our tiles to |maxNumTiles| if necessary.
* @param {?MostVisitedData} args Data for the tile to be rendered.
*/
function addTile(args) {
if (isFinite(args.rid)) {
// An actual suggestion. Grab the data from the embeddedSearch API.
const data =
chrome.embeddedSearch.newTabPage.getMostVisitedItemData(args.rid);
if (!data) {
return;
}
if (!data.faviconUrl) {
data.faviconUrl = 'chrome-search://favicon/size/16@' +
window.devicePixelRatio + 'x/' + data.renderViewId + '/' + data.rid;
}
tiles.appendChild(renderTile(data));
} else {
// An empty tile
tiles.appendChild(renderTile(null));
}
}
/**
* Called when the user decided to add a tile to the blacklist.
* It sets off the animation for the blacklist and sends the blacklisted id
* to the host page.
* @param {Element} tile DOM node of the tile we want to remove.
*/
function blacklistTile(tile) {
const rid = Number(tile.getAttribute('data-rid'));
if (isCustomLinksEnabled()) {
chrome.embeddedSearch.newTabPage.deleteMostVisitedItem(rid);
} else {
tile.classList.add('blacklisted');
tile.addEventListener('transitionend', function(ev) {
if (ev.propertyName != 'width') {
return;
}
window.parent.postMessage(
{cmd: 'tileBlacklisted', rid: Number(rid)}, DOMAIN_ORIGIN);
});
}
}
/**
* Starts edit custom link flow. Tells host page to show the edit custom link
* dialog and pre-populate it with data obtained using the link's id.
* @param {?number} rid Restricted id of the tile we want to edit.
*/
function editCustomLink(rid) {
window.parent.postMessage({cmd: 'startEditLink', rid: rid}, DOMAIN_ORIGIN);
}
/**
* Starts the reorder flow. Updates the visual style of the held tile to
* indicate that it is being moved.
* @param {!Element} tile Tile that is being moved.
*/
function startReorder(tile) {
reordering = true;
elementToReorder = tile;
tile.classList.add(CLASSES.REORDER);
// Disable other hover/active styling for all tiles.
document.body.classList.add(CLASSES.REORDERING);
document.addEventListener('dragend', () => {
stopReorder(tile);
}, {once: true});
}
/**
* Stops the reorder flow. Resets the held tile's visual style and tells the
* EmbeddedSearchAPI that a tile has been moved.
* @param {!Element} tile Tile that has been moved.
*/
function stopReorder(tile) {
reordering = false;
elementToReorder = null;
tile.classList.remove(CLASSES.REORDER);
document.body.classList.remove(CLASSES.REORDERING);
// Update |data-pos| for all tiles and notify EmbeddedSearchAPI that the tile
// has been moved.
const allTiles = document.querySelectorAll('#mv-tiles .' + CLASSES.MD_TILE);
for (let i = 0; i < allTiles.length; i++) {
allTiles[i].setAttribute('data-pos', i);
}
chrome.embeddedSearch.newTabPage.reorderCustomLink(
Number(tile.getAttribute('data-rid')),
Number(tile.getAttribute('data-pos')));
}
/**
* Sets up event listeners necessary for tile reordering.
* @param {!Element} tile Tile on which to set the event listeners.
*/
function setupReorder(tile) {
// Starts the reorder flow after the user has held the mouse button down for
// |REORDER_TIMEOUT_DELAY|.
tile.addEventListener('mousedown', (event) => {
// Do not reorder if the edit menu was clicked or if ctrl/shift/alt/meta is
// also held down.
if (event.button == 0 /* LEFT CLICK */ && !event.ctrlKey &&
!event.shiftKey && !event.altKey && !event.metaKey &&
!event.target.classList.contains(CLASSES.MD_MENU)) {
let timeout = -1;
// Cancel the timeout if the user drags the mouse off the tile and
// releases or if the mouse if released.
const dragend = () => {
window.clearTimeout(timeout);
};
document.addEventListener('dragend', dragend, {once: true});
const mouseup = () => {
if (event.button == 0 /* LEFT CLICK */) {
window.clearTimeout(timeout);
}
};
document.addEventListener('mouseup', mouseup, {once: true});
const timeoutFunc = (dragend_in, mouseup_in) => {
if (!reordering) {
startReorder(tile);
}
document.removeEventListener('dragend', dragend_in);
document.removeEventListener('mouseup', mouseup_in);
};
// Wait for |REORDER_TIMEOUT_DELAY| before starting the reorder flow.
timeout = window.setTimeout(
timeoutFunc.bind(dragend, mouseup), REORDER_TIMEOUT_DELAY);
}
});
tile.addEventListener('dragover', (event) => {
// Only executed when the reorder flow is ongoing. Inserts the tile that is
// being moved before/after this |tile| according to order in the list.
if (reordering && elementToReorder && elementToReorder != tile) {
// Determine which side to insert the element on:
// - If the held tile comes after the current tile, insert behind the
// current tile.
// - If the held tile comes before the current tile, insert in front of
// the current tile.
let insertBefore; // Element to insert the held tile behind.
if (tile.compareDocumentPosition(elementToReorder) &
Node.DOCUMENT_POSITION_FOLLOWING) {
insertBefore = tile;
} else {
insertBefore = tile.nextSibling;
}
$('mv-tiles').insertBefore(elementToReorder, insertBefore);
}
});
}
/**
* Renders a MostVisited tile to the DOM.
* @param {?MostVisitedData} data Object containing rid, url, title, favicon,
* thumbnail, and optionally isAddButton. isAddButton is true if you want to
* construct an add custom link button. data is null if you want to
* construct an empty tile. isAddButton can only be set if custom links is
* enabled.
*/
function renderTile(data) {
return renderMaterialDesignTile(data);
}
/**
* Renders a MostVisited tile with Material Design styles.
* @param {?MostVisitedData} data Object containing rid, url, title, favicon,
* and optionally isAddButton. isAddButton is if you want to construct an
* add custom link button. data is null if you want to construct an empty
* tile.
* @return {Element}
*/
function renderMaterialDesignTile(data) {
const mdTile = document.createElement('a');
if (data == null) {
mdTile.className = CLASSES.MD_EMPTY_TILE;
return mdTile;
}
mdTile.className = CLASSES.MD_TILE;
// The tile will be appended to |tiles|.
const position = tiles.children.length;
// This is set in the load/error event for the favicon image.
let tileType = TileVisualType.NONE;
mdTile.setAttribute('data-rid', data.rid);
mdTile.setAttribute('data-pos', position);
if (utils.isSchemeAllowed(data.url)) {
mdTile.href = data.url;
}
mdTile.setAttribute('aria-label', data.title);
mdTile.title = data.title;
mdTile.addEventListener('click', function(ev) {
if (data.isAddButton) {
editCustomLink(null);
logEvent(LOG_TYPE.NTP_CUSTOMIZE_ADD_SHORTCUT_CLICKED);
} else {
logMostVisitedNavigation(
position, data.tileTitleSource, data.tileSource, tileType,
data.dataGenerationTime);
}
});
mdTile.addEventListener('keydown', function(event) {
if ((event.keyCode === KEYCODES.DELETE ||
event.keyCode === KEYCODES.BACKSPACE) &&
!data.isAddButton) {
event.preventDefault();
event.stopPropagation();
blacklistTile(mdTile);
} else if (
event.keyCode === KEYCODES.ENTER || event.keyCode === KEYCODES.SPACE) {
event.preventDefault();
this.click();
} else if (event.keyCode === KEYCODES.LEFT) {
const tiles = document.querySelectorAll(
'#' + IDS.MV_TILES + ' .' + CLASSES.MD_TILE);
tiles[Math.max(Number(this.getAttribute('data-pos')) - 1, 0)].focus();
} else if (event.keyCode === KEYCODES.RIGHT) {
const tiles = document.querySelectorAll(
'#' + IDS.MV_TILES + ' .' + CLASSES.MD_TILE);
tiles[Math.min(
Number(this.getAttribute('data-pos')) + 1, tiles.length - 1)]
.focus();
}
});
utils.disableOutlineOnMouseClick(mdTile);
const mdTileInner = document.createElement('div');
mdTileInner.className = CLASSES.MD_TILE_INNER;
if (data.isAddButton) {
mdTile.tabIndex = 0;
const mdIconAdd = document.createElement('div');
mdIconAdd.classList.add(CLASSES.MD_ICON);
mdIconAdd.classList.add(CLASSES.MD_ADD_ICON);
mdTileInner.appendChild(mdIconAdd);
} else {
const mdIcon = document.createElement('img');
mdIcon.classList.add(CLASSES.MD_ICON);
// Set title and alt to empty so screen readers won't say the image name.
mdIcon.title = '';
mdIcon.alt = '';
const url = new URL('chrome-search://ntpicon/');
url.searchParams.set('size', '24@' + window.devicePixelRatio + 'x');
url.searchParams.set('url', data.url);
mdIcon.src = url.toString();
loadedCounter += 1;
mdIcon.addEventListener('load', function(ev) {
// Store the type for a potential later navigation.
tileType = TileVisualType.ICON_REAL;
logMostVisitedImpression(
position, data.tileTitleSource, data.tileSource, tileType,
data.dataGenerationTime);
// Note: It's important to call countLoad last, because that might emit
// the NTP_ALL_TILES_LOADED event, which must happen after the impression
// log.
countLoad();
});
mdIcon.addEventListener('error', function(ev) {
const fallbackBackground = document.createElement('div');
fallbackBackground.className = CLASSES.MD_ICON;
const fallbackLetter = document.createElement('div');
fallbackLetter.className = CLASSES.MD_FALLBACK_LETTER;
fallbackLetter.textContent = data.title.charAt(0).toUpperCase();
fallbackBackground.classList.add(CLASSES.FAILED_FAVICON);
fallbackBackground.appendChild(fallbackLetter);
mdTileInner.replaceChild(fallbackBackground, mdIcon);
// Store the type for a potential later navigation.
tileType = TileVisualType.ICON_DEFAULT;
logMostVisitedImpression(
position, data.tileTitleSource, data.tileSource, tileType,
data.dataGenerationTime);
// Note: It's important to call countLoad last, because that might emit
// the NTP_ALL_TILES_LOADED event, which must happen after the impression
// log.
countLoad();
});
mdTileInner.appendChild(mdIcon);
}
const mdTitle = document.createElement('div');
mdTitle.className = CLASSES.MD_TITLE;
mdTitle.style.direction = data.direction || 'ltr';
const mdTitleTextwrap = document.createElement('span');
mdTitleTextwrap.innerText = data.title;
mdTitle.appendChild(mdTitleTextwrap);
mdTileInner.appendChild(mdTitle);
mdTile.appendChild(mdTileInner);
if (!data.isAddButton) {
const mdMenu = document.createElement('button');
mdMenu.className = CLASSES.MD_MENU;
if (isCustomLinksEnabled()) {
mdMenu.classList.add(CLASSES.MD_EDIT_MENU);
mdMenu.title = queryArgs['editLinkTooltip'] || '';
mdMenu.setAttribute(
'aria-label',
(queryArgs['editLinkTooltip'] || '') + ' ' + data.title);
mdMenu.addEventListener('click', function(ev) {
editCustomLink(data.rid);
ev.preventDefault();
ev.stopPropagation();
logEvent(LOG_TYPE.NTP_CUSTOMIZE_EDIT_SHORTCUT_CLICKED);
});
} else {
mdMenu.title = queryArgs['removeTooltip'] || '';
mdMenu.setAttribute(
'aria-label', (queryArgs['removeTooltip'] || '') + ' ' + data.title);
mdMenu.addEventListener('click', function(ev) {
removeAllOldTiles();
blacklistTile(mdTile);
ev.preventDefault();
ev.stopPropagation();
});
}
// Don't allow the event to bubble out to the containing tile, as that would
// trigger navigation to the tile URL.
mdMenu.addEventListener('keydown', function(ev) {
ev.stopPropagation();
});
utils.disableOutlineOnMouseClick(mdMenu);
mdTile.appendChild(mdMenu);
}
if (isGridEnabled) {
return currGrid.createGridTile(mdTile, data.rid, !!data.isAddButton);
} else {
// Enable reordering.
if (isCustomLinksEnabled() && !data.isAddButton) {
mdTile.draggable = 'true';
setupReorder(mdTile);
}
return mdTile;
}
}
/**
* Does some initialization and parses the query arguments passed to the iframe.
*/
function init() {
// Create a new DOM element to hold the tiles. The tiles will be added
// one-by-one via addTile, and the whole thing will be inserted into the page
// in swapInNewTiles, after the parent has sent us the 'show' message, and all
// thumbnails and favicons have loaded.
tiles = document.createElement('div');
// Parse query arguments.
const query = window.location.search.substring(1).split('&');
queryArgs = {};
for (let i = 0; i < query.length; ++i) {
const val = query[i].split('=');
if (val[0] == '') {
continue;
}
queryArgs[decodeURIComponent(val[0])] = decodeURIComponent(val[1]);
}
document.title = queryArgs['title'];
// Enable RTL.
if (queryArgs['rtl'] == '1') {
document.documentElement.dir = 'rtl';
}
// Enable custom links.
if (queryArgs['enableCustomLinks'] == '1') {
customLinksFeatureEnabled = true;
}
// Enable grid layout.
if (queryArgs['enableGrid'] == '1') {
isGridEnabled = true;
document.body.classList.add(CLASSES.GRID_LAYOUT);
}
// Set the maximum number of tiles to show.
if (isCustomLinksEnabled()) {
maxNumTiles = MD_MAX_NUM_CUSTOM_LINK_TILES;
}
currGrid = new Grid();
// Set up layout updates on window resize. Throttled according to
// |RESIZE_TIMEOUT_DELAY|.
let resizeTimeout;
window.onresize = () => {
if (resizeTimeout) {
window.clearTimeout(resizeTimeout);
}
resizeTimeout = window.setTimeout(() => {
resizeTimeout = null;
if (isGridEnabled) {
currGrid.onResize();
} else {
updateTileVisibility();
}
}, RESIZE_TIMEOUT_DELAY);
};
window.addEventListener('message', handlePostMessage);
}
/**
* Binds event listeners.
*/
function listen() {
document.addEventListener('DOMContentLoaded', init);
}
return {
Grid: Grid, // Exposed for testing.
init: init, // Exposed for testing.
enableGridLayoutForTesting: enableGridLayoutForTesting,
listen: listen,
};
}
if (!window.mostVisitedUnitTest) {
MostVisited().listen();
}