blob: ef797cb486a7a088865b719fd20bc1e18e5566da [file] [log] [blame]
// Copyright 2021 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.
import 'chrome://resources/cr_elements/hidden_style_css.m.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.m.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../i18n_setup.js';
import {NewTabPageProxy} from '../new_tab_page_proxy.js';
import {ModuleHeight} from './module_descriptor.js';
import {ModuleRegistry} from './module_registry.js';
import {ModuleWrapperElement} from './module_wrapper.js';
import {getTemplate} from './modules.html.js';
export type DismissModuleEvent =
CustomEvent<{message: string, restoreCallback: () => void}>;
export type DisableModuleEvent = DismissModuleEvent;
declare global {
interface HTMLElementEventMap {
'dismiss-module': DismissModuleEvent;
'disable-module': DisableModuleEvent;
}
}
export interface ModulesElement {
$: {
modules: HTMLElement,
removeModuleToast: CrToastElement,
removeModuleToastMessage: HTMLElement,
undoRemoveModuleButton: HTMLElement,
};
}
const SHORT_CLASS_NAME: string = 'short';
const TALL_CLASS_NAME: string = 'tall';
/** Container for the NTP modules. */
export class ModulesElement extends PolymerElement {
static get is() {
return 'ntp-modules';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
dismissedModules_: {
type: Array,
value: () => [],
},
disabledModules_: {
type: Object,
value: () => ({all: true, ids: []}),
},
/** Data about the most recently removed module. */
removedModuleData_: {
type: Object,
value: null,
},
modulesLoaded_: Boolean,
modulesVisibilityDetermined_: Boolean,
modulesLoadedAndVisibilityDetermined_: {
type: Boolean,
computed: `computeModulesLoadedAndVisibilityDetermined_(
modulesLoaded_,
modulesVisibilityDetermined_)`,
observer: 'onModulesLoadedAndVisibilityDeterminedChange_',
},
dragEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesDragAndDropEnabled'),
reflectToAttribute: true,
},
/** @private {boolean} */
modulesRedesignedLayoutEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('modulesRedesignedLayoutEnabled'),
reflectToAttribute: true,
},
};
}
static get observers() {
return ['onRemovedModulesChange_(dismissedModules_.*, disabledModules_)'];
}
private dismissedModules_: string[];
private disabledModules_: {all: boolean, ids: string[]};
private removedModuleData_: {message: string, undo: () => void}|null;
private modulesLoaded_: boolean;
private modulesVisibilityDetermined_: boolean;
private modulesLoadedAndVisibilityDetermined_: boolean;
private dragEnabled_: boolean;
private setDisabledModulesListenerId_: number|null = null;
private eventTracker_: EventTracker = new EventTracker();
connectedCallback() {
super.connectedCallback();
this.setDisabledModulesListenerId_ =
NewTabPageProxy.getInstance()
.callbackRouter.setDisabledModules.addListener(
(all: boolean, ids: string[]) => {
this.disabledModules_ = {all, ids};
this.modulesVisibilityDetermined_ = true;
});
NewTabPageProxy.getInstance().handler.updateDisabledModules();
this.eventTracker_.add(window, 'keydown', this.onWindowKeydown_.bind(this));
}
disconnectedCallback() {
super.disconnectedCallback();
NewTabPageProxy.getInstance().callbackRouter.removeListener(
assert(this.setDisabledModulesListenerId_!));
this.eventTracker_.removeAll();
}
ready() {
super.ready();
this.renderModules_();
}
private appendModuleContainers_(moduleContainers: HTMLElement[]) {
this.$.modules.innerHTML = '';
let shortModuleSiblingsContainer: HTMLElement|null = null;
moduleContainers.forEach((moduleContainer: HTMLElement, index: number) => {
let moduleContainerParent = this.$.modules;
if (loadTimeData.getBoolean('modulesRedesignedLayoutEnabled')) {
// Wrap pairs of sibling short modules in a container. All other
// modules will be placed in a container of their own.
if (moduleContainer.classList.contains(SHORT_CLASS_NAME) &&
shortModuleSiblingsContainer) {
// Add current sibling short module to container which already
// contains the previous sibling short module by setting its parent
// to be 'shortModuleSiblingsContainer'.
moduleContainerParent = shortModuleSiblingsContainer;
this.$.modules.appendChild(shortModuleSiblingsContainer);
shortModuleSiblingsContainer = null;
} else if (
moduleContainer.classList.contains(SHORT_CLASS_NAME) &&
index + 1 !== moduleContainers.length &&
moduleContainers[index + 1].classList.contains(SHORT_CLASS_NAME)) {
// Add current short module to a new container since the next one is
// short as well by setting its parent to be
// 'shortModuleSiblingsContainer'.
shortModuleSiblingsContainer =
this.ownerDocument.createElement('div');
shortModuleSiblingsContainer.classList.add(
'short-module-siblings-container');
moduleContainerParent = shortModuleSiblingsContainer;
}
}
moduleContainerParent.appendChild(moduleContainer);
});
}
private async renderModules_(): Promise<void> {
const modules = await ModuleRegistry.getInstance().initializeModules(
loadTimeData.getInteger('modulesLoadTimeout'));
if (modules) {
NewTabPageProxy.getInstance().handler.onModulesLoadedWithData();
const moduleContainers = modules.map(module => {
const moduleWrapper = new ModuleWrapperElement();
moduleWrapper.module = module;
if (this.dragEnabled_) {
moduleWrapper.addEventListener(
'mousedown', e => this.onDragStart_(e));
}
if (!loadTimeData.getBoolean('modulesRedesignedEnabled')) {
moduleWrapper.addEventListener(
'dismiss-module', e => this.onDismissModule_(e));
}
moduleWrapper.addEventListener(
'disable-module', e => this.onDisableModule_(e));
const moduleContainer = this.ownerDocument.createElement('div');
moduleContainer.classList.add('module-container');
if (loadTimeData.getBoolean('modulesRedesignedLayoutEnabled')) {
if (module.descriptor.height === ModuleHeight.SHORT) {
moduleContainer.classList.add(SHORT_CLASS_NAME);
}
if (module.descriptor.height === ModuleHeight.TALL) {
moduleContainer.classList.add(TALL_CLASS_NAME);
}
}
moduleContainer.hidden = this.moduleDisabled_(module.descriptor.id);
moduleContainer.appendChild(moduleWrapper);
return moduleContainer;
});
this.appendModuleContainers_(moduleContainers);
this.onModulesLoaded_();
}
}
private onWindowKeydown_(e: KeyboardEvent) {
let ctrlKeyPressed = e.ctrlKey;
// <if expr="is_macosx">
ctrlKeyPressed = ctrlKeyPressed || e.metaKey;
// </if>
if (ctrlKeyPressed && e.key === 'z') {
this.onUndoRemoveModuleButtonClick_();
}
}
private onModulesLoaded_() {
this.modulesLoaded_ = true;
}
private computeModulesLoadedAndVisibilityDetermined_(): boolean {
return this.modulesLoaded_ && this.modulesVisibilityDetermined_;
}
/** @private */
onModulesLoadedAndVisibilityDeterminedChange_() {
if (this.modulesLoadedAndVisibilityDetermined_) {
ModuleRegistry.getInstance().getDescriptors().forEach(({id}) => {
chrome.metricsPrivate.recordBoolean(
`NewTabPage.Modules.EnabledOnNTPLoad.${id}`,
!this.disabledModules_.all &&
!this.disabledModules_.ids.includes(id));
});
chrome.metricsPrivate.recordBoolean(
'NewTabPage.Modules.VisibleOnNTPLoad', !this.disabledModules_.all);
this.dispatchEvent(new Event('modules-loaded'));
}
}
/**
* @param e Event notifying a module was dismissed. Contains the message to
* show in the toast.
*/
private onDismissModule_(e: DismissModuleEvent) {
const id = (e.target as ModuleWrapperElement).module.descriptor.id;
const restoreCallback = e.detail.restoreCallback;
this.removedModuleData_ = {
message: e.detail.message,
undo: () => {
this.splice('dismissedModules_', this.dismissedModules_.indexOf(id), 1);
restoreCallback();
NewTabPageProxy.getInstance().handler.onRestoreModule(id);
},
};
if (!this.dismissedModules_.includes(id)) {
this.push('dismissedModules_', id);
}
// Notify the user.
this.$.removeModuleToast.show();
// Notify the backend.
NewTabPageProxy.getInstance().handler.onDismissModule(id);
}
/**
* @param e Event notifying a module was disabled. Contains the message to
* show in the toast.
*/
private onDisableModule_(e: DisableModuleEvent) {
const id = (e.target as ModuleWrapperElement).module.descriptor.id;
const restoreCallback = e.detail.restoreCallback;
this.removedModuleData_ = {
message: e.detail.message,
undo: () => {
if (restoreCallback) {
restoreCallback();
}
NewTabPageProxy.getInstance().handler.setModuleDisabled(id, false);
chrome.metricsPrivate.recordSparseHashable(
'NewTabPage.Modules.Enabled', id);
chrome.metricsPrivate.recordSparseHashable(
'NewTabPage.Modules.Enabled.Toast', id);
},
};
NewTabPageProxy.getInstance().handler.setModuleDisabled(id, true);
this.$.removeModuleToast.show();
chrome.metricsPrivate.recordSparseHashable(
'NewTabPage.Modules.Disabled', id);
chrome.metricsPrivate.recordSparseHashable(
'NewTabPage.Modules.Disabled.ModuleRequest', id);
}
private moduleDisabled_(id: string): boolean {
return this.disabledModules_.all || this.dismissedModules_.includes(id) ||
this.disabledModules_.ids.includes(id);
}
/** @private */
onUndoRemoveModuleButtonClick_() {
if (!this.removedModuleData_) {
return;
}
// Restore the module.
this.removedModuleData_.undo();
// Notify the user.
this.$.removeModuleToast.hide();
this.removedModuleData_ = null;
}
/**
* Hides and reveals modules depending on removed status.
* @private
*/
onRemovedModulesChange_() {
this.shadowRoot!.querySelectorAll('ntp-module-wrapper')
.forEach(moduleWrapper => {
moduleWrapper.parentElement!.hidden =
this.moduleDisabled_(moduleWrapper.module.descriptor.id);
});
}
/**
* Module is dragged by updating the module position based on the
* position of the pointer.
*/
private onDragStart_(e: MouseEvent) {
assert(loadTimeData.getBoolean('modulesDragAndDropEnabled'));
const dragElement = e.target as HTMLElement;
const dragElementRect = dragElement.getBoundingClientRect();
// This is the offset between the pointer and module so that the
// module isn't dragged by the top-left corner.
const dragOffset = {
x: e.x - dragElementRect.x,
y: e.y - dragElementRect.y,
};
dragElement.parentElement!.style.width = `${dragElementRect.width}px`;
dragElement.parentElement!.style.height = `${dragElementRect.height}px`;
const undraggedModuleWrappers =
[...this.shadowRoot!.querySelectorAll('ntp-module-wrapper')].filter(
moduleWrapper => moduleWrapper !== dragElement);
const dragOver = (e: MouseEvent) => {
e.preventDefault();
dragElement.setAttribute('dragging', '');
dragElement.style.left = `${e.x - dragOffset.x}px`;
dragElement.style.top = `${e.y - dragOffset.y}px`;
};
const dragEnter = (e: MouseEvent) => {
const moduleContainers = [
...this.shadowRoot!.querySelectorAll<HTMLElement>('.module-container')
];
const dragIndex = moduleContainers.indexOf(dragElement.parentElement!);
const dropIndex =
moduleContainers.indexOf((e.target as HTMLElement).parentElement!);
const positionType = dragIndex > dropIndex ? 'beforebegin' : 'afterend';
const dragContainer = moduleContainers[dragIndex];
const dropContainer = moduleContainers[dropIndex];
// To animate the modules as they are reordered we use the FLIP
// (First, Last, Invert, Play) animation approach by @paullewis.
// https://aerotwist.com/blog/flip-your-animations/
// The first and last positions of the modules are used to
// calculate how the modules have changed.
const firstRects = undraggedModuleWrappers.map(moduleWrapper => {
return moduleWrapper.getBoundingClientRect();
});
moduleContainers.splice(dragIndex, 1);
moduleContainers.splice(dropIndex, 0, dragContainer);
this.appendModuleContainers_(moduleContainers);
undraggedModuleWrappers.forEach((moduleWrapper, i) => {
const lastRect = moduleWrapper.getBoundingClientRect();
const invertX = firstRects[i].left - lastRect.left;
const invertY = firstRects[i].top - lastRect.top;
moduleWrapper.animate(
[
// A translation is applied to invert the module and make it
// appear to be in its first position when it actually is in its
// final position already.
{transform: `translate(${invertX}px, ${invertY}px)`, zIndex: 0},
// Removing the inversion translation animates the module from
// the fake first position to its current (final) position.
{transform: 'translate(0)', zIndex: 0},
],
{duration: 800, easing: 'ease'});
});
};
undraggedModuleWrappers.forEach(moduleWrapper => {
moduleWrapper.addEventListener('mouseover', dragEnter);
});
this.ownerDocument.addEventListener('mousemove', dragOver);
this.ownerDocument.addEventListener('mouseup', () => {
this.ownerDocument.removeEventListener('mousemove', dragOver);
undraggedModuleWrappers.forEach(moduleWrapper => {
moduleWrapper.removeEventListener('mouseover', dragEnter);
});
// The FLIP approach is also used for the dropping animation
// of the dragged module because of the position changes caused
// by removing the dragging styles. (Removing the styles after
// the animation causes the animation to be fixed.)
const firstRect = dragElement.getBoundingClientRect();
dragElement.removeAttribute('dragging');
dragElement.style.removeProperty('left');
dragElement.style.removeProperty('top');
const lastRect = dragElement.getBoundingClientRect();
const invertX = firstRect.left - lastRect.left;
const invertY = firstRect.top - lastRect.top;
dragElement.animate(
[
{transform: `translate(${invertX}px, ${invertY}px)`, zIndex: 2},
{transform: 'translate(0)', zIndex: 2},
],
{duration: 800, easing: 'ease'});
const moduleIds =
[...this.shadowRoot!.querySelectorAll('ntp-module-wrapper')].map(
moduleWrapper => moduleWrapper.module.descriptor.id);
NewTabPageProxy.getInstance().handler.setModulesOrder(moduleIds);
}, {once: true});
}
}
customElements.define(ModulesElement.is, ModulesElement);