blob: 23a2e9f982f2c42599945c3a8b488c9c0154a40b [file] [log] [blame]
// Copyright 2019 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 './strings.m.js';
import './tab.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import 'chrome://resources/cr_elements/icons.m.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {addWebUIListener, removeWebUIListener, WebUIListener} from 'chrome://resources/js/cr.m.js';
import {FocusOutlineManager} from 'chrome://resources/js/cr/ui/focus_outline_manager.m.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {isRTL} from 'chrome://resources/js/util.m.js';
import {DragManager, DragManagerDelegate} from './drag_manager.js';
import {isTabElement, TabElement} from './tab.js';
import {isTabGroupElement, TabGroupElement} from './tab_group.js';
import {TabStripEmbedderProxy, TabStripEmbedderProxyImpl} from './tab_strip_embedder_proxy.js';
import {TabData, TabGroupVisualData, TabsApiProxy, TabsApiProxyImpl} from './tabs_api_proxy.js';
/**
* The amount of padding to leave between the edge of the screen and the active
* tab when auto-scrolling. This should leave some room to show the previous or
* next tab to afford to users that there more tabs if the user scrolls.
* @const {number}
*/
const SCROLL_PADDING = 32;
/** @type {boolean} */
let scrollAnimationEnabled = true;
/** @const {number} */
const TOUCH_CONTEXT_MENU_OFFSET_X = 8;
/** @const {number} */
const TOUCH_CONTEXT_MENU_OFFSET_Y = -40;
/**
* Context menu should position below the element for touch.
* @param {!Element} element
* @return {!Object<{x: number, y: number}>}
*/
function getContextMenuPosition(element) {
const rect = element.getBoundingClientRect();
return {
x: rect.left + TOUCH_CONTEXT_MENU_OFFSET_X,
y: rect.bottom + TOUCH_CONTEXT_MENU_OFFSET_Y
};
}
/** @param {boolean} enabled */
export function setScrollAnimationEnabledForTesting(enabled) {
scrollAnimationEnabled = enabled;
}
/**
* @enum {string}
*/
const LayoutVariable = {
VIEWPORT_WIDTH: '--tabstrip-viewport-width',
NEW_TAB_BUTTON_MARGIN: '--tabstrip-new-tab-button-margin',
NEW_TAB_BUTTON_WIDTH: '--tabstrip-new-tab-button-width',
TAB_WIDTH: '--tabstrip-tab-thumbnail-width',
};
/**
* Animates a series of elements to indicate that tabs have moved position.
* @param {!Element} movedElement
* @param {number} prevIndex
* @param {number} newIndex
*/
function animateElementMoved(movedElement, prevIndex, newIndex) {
// Direction is -1 for moving towards a lower index, +1 for moving
// towards a higher index. If moving towards a lower index, the TabList needs
// to animate everything from the movedElement's current index to its prev
// index by traversing the nextElementSibling of each element because the
// movedElement is now at a preceding position from all the elements it has
// slid across. If moving towards a higher index, the TabList needs to
// traverse the previousElementSiblings.
const direction = Math.sign(newIndex - prevIndex);
/**
* @param {!Element} element
* @return {?Element}
*/
function getSiblingToAnimate(element) {
return direction === -1 ? element.nextElementSibling :
element.previousElementSibling;
}
let elementToAnimate = getSiblingToAnimate(movedElement);
for (let i = newIndex; i !== prevIndex && elementToAnimate; i -= direction) {
const elementToAnimatePrevIndex = i;
const elementToAnimateNewIndex = i - direction;
slideElement(
elementToAnimate, elementToAnimatePrevIndex, elementToAnimateNewIndex);
elementToAnimate = getSiblingToAnimate(elementToAnimate);
}
slideElement(movedElement, prevIndex, newIndex);
}
/**
* Animates the slide of an element across the tab strip (both vertically and
* horizontally for pinned tabs, and horizontally for other tabs and groups).
* @param {!Element} element
* @param {number} prevIndex
* @param {number} newIndex
*/
function slideElement(element, prevIndex, newIndex) {
let horizontalMovement = newIndex - prevIndex;
let verticalMovement = 0;
if (isTabElement(element) && element.tab.pinned) {
const pinnedTabsPerColumn = 3;
const columnChange = Math.floor(newIndex / pinnedTabsPerColumn) -
Math.floor(prevIndex / pinnedTabsPerColumn);
horizontalMovement = columnChange;
verticalMovement =
(newIndex - prevIndex) - (columnChange * pinnedTabsPerColumn);
}
horizontalMovement *= isRTL() ? -1 : 1;
const translateX = `calc(${horizontalMovement * -1} * ` +
'(var(--tabstrip-tab-width) + var(--tabstrip-tab-spacing)))';
const translateY = `calc(${verticalMovement * -1} * ` +
'(var(--tabstrip-tab-height) + var(--tabstrip-tab-spacing)))';
element.isValidDragOverTarget = false;
const animation = element.animate(
[
{transform: `translate(${translateX}, ${translateY})`},
{transform: 'translate(0, 0)'},
],
{
duration: 120,
easing: 'ease-out',
});
function onComplete() {
element.isValidDragOverTarget = true;
}
animation.oncancel = onComplete;
animation.onfinish = onComplete;
}
/** @implements {DragManagerDelegate} */
export class TabListElement extends CustomElement {
static get template() {
return `{__html_template__}`;
}
constructor() {
super();
/**
* A chain of promises that the tab list needs to keep track of. The chain
* is useful in cases when the list needs to wait for all animations to
* finish in order to get accurate pixels (such as getting the position of a
* tab) or accurate element counts.
* @type {!Promise}
*/
this.animationPromises = Promise.resolve();
/**
* The ID of the current animation frame that is in queue to update the
* scroll position.
* @private {?number}
*/
this.currentScrollUpdateFrame_ = null;
/** @private {!Function} */
this.documentVisibilityChangeListener_ = () =>
this.onDocumentVisibilityChange_();
/**
* The element that is currently being dragged.
* @private {!TabElement|!TabGroupElement|undefined}
*/
this.draggedItem_;
/** @private {!Element} */
this.dropPlaceholder_ = document.createElement('div');
this.dropPlaceholder_.id = 'dropPlaceholder';
/** @private @const {!FocusOutlineManager} */
this.focusOutlineManager_ = FocusOutlineManager.forDocument(document);
/**
* Map of tab IDs to whether or not the tab's thumbnail should be tracked.
* @private {!Map<number, boolean>}
*/
this.thumbnailTracker_ = new Map();
/**
* An intersection observer is needed to observe which TabElements are
* currently in view or close to being in view, which will help determine
* which thumbnails need to be tracked to stay fresh and which can be
* untracked until they become visible.
* @private {!IntersectionObserver}
*/
this.intersectionObserver_ = new IntersectionObserver(entries => {
for (const entry of entries) {
this.thumbnailTracker_.set(entry.target.tab.id, entry.isIntersecting);
}
if (this.scrollingTimeoutId_ === -1) {
// If there is no need to wait for scroll to end, immediately process
// and request thumbnails.
this.flushThumbnailTracker_();
}
}, {
root: this,
// The horizontal root margin is set to 100% to also track thumbnails that
// are one standard finger swipe away.
rootMargin: '0% 100%',
});
/** @private {number|undefined} */
this.activatingTabId_;
/** @private {number|undefined} Timestamp in ms */
this.activatingTabIdTimestamp_;
/** @private @const {!EventTracker} */
this.eventTracker_ = new EventTracker();
/** @private {!TabElement|null} */
this.lastTargetedTab_;
/** @private {!Object<{x: number, y: number}>|undefined} */
this.lastTouchPoint_;
/** @private {!Element} */
this.newTabButtonElement_ =
/** @type {!Element} */ (this.$('#newTabButton'));
/** @private {!Element} */
this.pinnedTabsElement_ = /** @type {!Element} */ (this.$('#pinnedTabs'));
/** @private {!TabStripEmbedderProxy} */
this.tabStripEmbedderProxy_ = TabStripEmbedderProxyImpl.getInstance();
/** @private {!TabsApiProxy} */
this.tabsApi_ = TabsApiProxyImpl.getInstance();
/** @private {!Element} */
this.unpinnedTabsElement_ =
/** @type {!Element} */ (this.$('#unpinnedTabs'));
/** @private {!Array<!WebUIListener>} */
this.webUIListeners_ = [];
/** @private {!Function} */
this.windowBlurListener_ = () => this.onWindowBlur_();
/**
* Timeout that is created at every scroll event and is either canceled at
* each subsequent scroll event or resolves after a few milliseconds after
* the last scroll event.
* @private {number}
*/
this.scrollingTimeoutId_ = -1;
/** @private {!Function} */
this.scrollListener_ = (e) => this.onScroll_(e);
this.addWebUIListener_(
'layout-changed', layout => this.applyCSSDictionary_(layout));
this.addWebUIListener_('theme-changed', () => {
this.fetchAndUpdateColors_();
this.fetchAndUpdateGroupData_();
});
this.tabStripEmbedderProxy_.observeThemeChanges();
this.addWebUIListener_(
'tab-thumbnail-updated', this.tabThumbnailUpdated_.bind(this));
this.addWebUIListener_('long-press', () => this.handleLongPress_());
this.addWebUIListener_(
'context-menu-closed', () => this.clearLastTargetedTab_());
this.eventTracker_.add(
document, 'contextmenu', e => this.onContextMenu_(e));
this.eventTracker_.add(
document, 'pointerup',
e => this.onPointerUp_(/** @type {!PointerEvent} */ (e)));
this.eventTracker_.add(
document, 'visibilitychange', () => this.onDocumentVisibilityChange_());
this.eventTracker_.add(window, 'blur', () => this.onWindowBlur_());
this.eventTracker_.add(this, 'scroll', e => this.onScroll_(e));
this.eventTracker_.add(
document, 'touchstart', (e) => this.onTouchStart_(e));
// Touchend events happen when a touch gesture finishes normally (ie not due
// to the context menu appearing or drag starting). Clear the last targeted
// tab on a drag end to ensure `lastTargetedTab_` is cleared for the cases
// that do not end with a dragstart or the context menu appearing.
this.eventTracker_.add(
document, 'touchend', () => this.clearLastTargetedTab_());
this.addWebUIListener_(
'received-keyboard-focus', () => this.onReceivedKeyboardFocus_());
this.newTabButtonElement_.addEventListener('click', () => {
this.tabsApi_.createNewTab();
});
const dragManager = new DragManager(this);
dragManager.startObserving();
if (!loadTimeData.getBoolean('newTabButtonEnabled')) {
this.style.setProperty(LayoutVariable.NEW_TAB_BUTTON_MARGIN, '0');
this.style.setProperty(LayoutVariable.NEW_TAB_BUTTON_WIDTH, '0');
}
}
/**
* @param {!Promise} promise
* @private
*/
addAnimationPromise_(promise) {
this.animationPromises = this.animationPromises.then(() => promise);
}
/**
* @param {string} eventName
* @param {!Function} callback
* @private
*/
addWebUIListener_(eventName, callback) {
this.webUIListeners_.push(addWebUIListener(eventName, callback));
}
/**
* @param {number} scrollBy
* @private
*/
animateScrollPosition_(scrollBy) {
if (this.currentScrollUpdateFrame_) {
cancelAnimationFrame(this.currentScrollUpdateFrame_);
this.currentScrollUpdateFrame_ = null;
}
const prevScrollLeft = this.scrollLeft;
if (!scrollAnimationEnabled || !this.tabStripEmbedderProxy_.isVisible()) {
// Do not animate if tab strip is not visible.
this.scrollLeft = prevScrollLeft + scrollBy;
return;
}
const duration = 350;
let startTime;
const onAnimationFrame = (currentTime) => {
const startScroll = this.scrollLeft;
if (!startTime) {
startTime = currentTime;
}
const elapsedRatio = Math.min(1, (currentTime - startTime) / duration);
// The elapsed ratio should be decelerated such that the elapsed time
// of the animation gets less and less further apart as time goes on,
// giving the effect of an animation that slows down towards the end. When
// 0ms has passed, the decelerated ratio should be 0. When the full
// duration has passed, the ratio should be 1.
const deceleratedRatio =
1 - (1 - elapsedRatio) / Math.pow(2, 6 * elapsedRatio);
this.scrollLeft = prevScrollLeft + (scrollBy * deceleratedRatio);
this.currentScrollUpdateFrame_ =
deceleratedRatio < 1 ? requestAnimationFrame(onAnimationFrame) : null;
};
this.currentScrollUpdateFrame_ = requestAnimationFrame(onAnimationFrame);
}
/**
* @param {!Object<string, string>} dictionary
* @private
*/
applyCSSDictionary_(dictionary) {
for (const [cssVariable, value] of Object.entries(dictionary)) {
this.style.setProperty(cssVariable, value);
}
}
/** @private */
clearScrollTimeout_() {
clearTimeout(this.scrollingTimeoutId_);
this.scrollingTimeoutId_ = -1;
}
connectedCallback() {
this.tabStripEmbedderProxy_.getLayout().then(
layout => this.applyCSSDictionary_(layout));
this.fetchAndUpdateColors_();
const getTabsStartTimestamp = Date.now();
this.tabsApi_.getTabs().then(tabs => {
this.tabStripEmbedderProxy_.reportTabDataReceivedDuration(
tabs.length, Date.now() - getTabsStartTimestamp);
const createTabsStartTimestamp = Date.now();
tabs.forEach(tab => this.onTabCreated_(tab));
this.fetchAndUpdateGroupData_();
this.tabStripEmbedderProxy_.reportTabCreationDuration(
tabs.length, Date.now() - createTabsStartTimestamp);
this.addWebUIListener_(
'show-context-menu', () => this.onShowContextMenu_());
this.addWebUIListener_('tab-created', tab => this.onTabCreated_(tab));
this.addWebUIListener_(
'tab-moved',
(tabId, newIndex, pinned) =>
this.onTabMoved_(tabId, newIndex, pinned));
this.addWebUIListener_('tab-removed', tabId => this.onTabRemoved_(tabId));
this.addWebUIListener_(
'tab-replaced', (oldId, newId) => this.onTabReplaced_(oldId, newId));
this.addWebUIListener_('tab-updated', tab => this.onTabUpdated_(tab));
this.addWebUIListener_(
'tab-active-changed', tabId => this.onTabActivated_(tabId));
this.addWebUIListener_(
'tab-close-cancelled', tabId => this.onTabCloseCancelled_(tabId));
this.addWebUIListener_(
'tab-group-state-changed',
(tabId, index, groupId) =>
this.onTabGroupStateChanged_(tabId, index, groupId));
this.addWebUIListener_(
'tab-group-closed', groupId => this.onTabGroupClosed_(groupId));
this.addWebUIListener_(
'tab-group-moved',
(groupId, index) => this.onTabGroupMoved_(groupId, index));
this.addWebUIListener_(
'tab-group-visuals-changed',
(groupId, visualData) =>
this.onTabGroupVisualsChanged_(groupId, visualData));
});
}
disconnectedCallback() {
this.webUIListeners_.forEach(removeWebUIListener);
this.eventTracker_.removeAll();
}
/**
* @param {!TabData} tab
* @return {!TabElement}
* @private
*/
createTabElement_(tab) {
const tabElement = new TabElement();
tabElement.tab = tab;
tabElement.onTabActivating = (id) => {
this.onTabActivating_(id);
};
return tabElement;
}
/**
* @param {number} tabId
* @return {?TabElement}
* @private
*/
findTabElement_(tabId) {
return /** @type {?TabElement} */ (
this.$(`tabstrip-tab[data-tab-id="${tabId}"]`));
}
/**
* @param {string} groupId
* @return {?TabGroupElement}
* @private
*/
findTabGroupElement_(groupId) {
return /** @type {?TabGroupElement} */ (
this.$(`tabstrip-tab-group[data-group-id="${groupId}"]`));
}
/** @private */
fetchAndUpdateColors_() {
this.tabStripEmbedderProxy_.getColors().then(
colors => this.applyCSSDictionary_(colors));
}
/** @private */
fetchAndUpdateGroupData_() {
const tabGroupElements = this.$all('tabstrip-tab-group');
this.tabsApi_.getGroupVisualData().then(data => {
tabGroupElements.forEach(tabGroupElement => {
tabGroupElement.updateVisuals(
assert(data[tabGroupElement.dataset.groupId]));
});
});
}
/**
* @return {?TabElement}
* @private
*/
getActiveTab_() {
return /** @type {?TabElement} */ (this.$('tabstrip-tab[active]'));
}
/**
* @param {!TabElement} tabElement
* @return {number}
*/
getIndexOfTab(tabElement) {
return Array.prototype.indexOf.call(this.$all('tabstrip-tab'), tabElement);
}
/**
* @param {!LayoutVariable} variable
* @return {number} in pixels
*/
getLayoutVariable_(variable) {
return parseInt(this.style.getPropertyValue(variable), 10);
}
/** @private */
handleLongPress_() {
if (this.lastTargetedTab_) {
this.lastTargetedTab_.setTouchPressed(true);
}
}
/**
* @param {!Event} event
* @private
*/
onContextMenu_(event) {
// Prevent the default context menu from triggering.
event.preventDefault();
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerUp_(event) {
event.stopPropagation();
if (event.pointerType !== 'touch' && event.button === 2) {
// If processing an uncaught right click event show the background context
// menu.
this.tabStripEmbedderProxy_.showBackgroundContextMenu(
event.clientX, event.clientY);
}
}
/** @private */
onDocumentVisibilityChange_() {
if (!this.tabStripEmbedderProxy_.isVisible()) {
this.scrollToActiveTab_();
}
this.unpinnedTabsElement_.childNodes.forEach(element => {
if (isTabGroupElement(/** @type {!Element} */ (element))) {
element.childNodes.forEach(
tabElement => this.updateThumbnailTrackStatus_(
/** @type {!TabElement} */ (tabElement)));
} else {
this.updateThumbnailTrackStatus_(
/** @type {!TabElement} */ (element));
}
});
}
/** @private */
onReceivedKeyboardFocus_() {
// FocusOutlineManager relies on the most recent event fired on the
// document. When the tab strip first gains keyboard focus, no such event
// exists yet, so the outline needs to be explicitly set to visible.
this.focusOutlineManager_.visible = true;
this.$('tabstrip-tab').focus();
}
/**
* @param {number} tabId
* @private
*/
onTabActivated_(tabId) {
if (this.activatingTabId_ === tabId) {
this.tabStripEmbedderProxy_.reportTabActivationDuration(
Date.now() - this.activatingTabIdTimestamp_);
}
this.activatingTabId_ = undefined;
this.activatingTabIdTimestamp_ = undefined;
// There may be more than 1 TabElement marked as active if other events
// have updated a Tab to have an active state. For example, if a
// tab is created with an already active state, there may be 2 active
// TabElements: the newly created tab and the previously active tab.
this.$all('tabstrip-tab[active]').forEach((previouslyActiveTab) => {
if (previouslyActiveTab.tab.id !== tabId) {
previouslyActiveTab.tab = /** @type {!TabData} */ (
Object.assign({}, previouslyActiveTab.tab, {active: false}));
}
});
const newlyActiveTab = this.findTabElement_(tabId);
if (newlyActiveTab) {
newlyActiveTab.tab = /** @type {!TabData} */ (
Object.assign({}, newlyActiveTab.tab, {active: true}));
if (!this.tabStripEmbedderProxy_.isVisible()) {
this.scrollToTab_(newlyActiveTab);
}
}
}
/**
* @param {number} id The tab ID
* @private
*/
onTabActivating_(id) {
assert(this.activatingTabId_ === undefined);
const activeTab = this.getActiveTab_();
if (activeTab && activeTab.tab.id === id) {
return;
}
this.activatingTabId_ = id;
this.activatingTabIdTimestamp_ = Date.now();
}
/**
* @param {number} id
* @private
*/
onTabCloseCancelled_(id) {
const tabElement = this.findTabElement_(id);
if (!tabElement) {
return;
}
tabElement.resetSwipe();
}
/** @private */
onShowContextMenu_() {
// If we do not have a touch point don't show the context menu.
if (!this.lastTouchPoint_) {
return;
}
if (this.lastTargetedTab_) {
const position = getContextMenuPosition(this.lastTargetedTab_);
this.tabStripEmbedderProxy_.showTabContextMenu(
this.lastTargetedTab_.tab.id, position.x, position.y);
} else {
this.tabStripEmbedderProxy_.showBackgroundContextMenu(
this.lastTouchPoint_.clientX, this.lastTouchPoint_.clientY);
}
}
/**
* @param {!TabData} tab
* @private
*/
onTabCreated_(tab) {
const droppedTabElement = this.findTabElement_(tab.id);
if (droppedTabElement) {
droppedTabElement.tab = tab;
droppedTabElement.setDragging(false);
this.tabsApi_.setThumbnailTracked(tab.id, true);
return;
}
const tabElement = this.createTabElement_(tab);
this.placeTabElement(tabElement, tab.index, tab.pinned, tab.groupId);
this.addAnimationPromise_(tabElement.slideIn());
if (tab.active) {
this.scrollToTab_(tabElement);
}
}
/**
* @param {string} groupId
* @private
*/
onTabGroupClosed_(groupId) {
const tabGroupElement = this.findTabGroupElement_(groupId);
if (!tabGroupElement) {
return;
}
tabGroupElement.remove();
}
/**
* @param {string} groupId
* @param {number} index
* @private
*/
onTabGroupMoved_(groupId, index) {
const tabGroupElement = this.findTabGroupElement_(groupId);
if (!tabGroupElement) {
return;
}
this.placeTabGroupElement(tabGroupElement, index);
}
/**
* @param {number} tabId
* @param {number} index
* @param {string} groupId
* @private
*/
onTabGroupStateChanged_(tabId, index, groupId) {
const tabElement = this.findTabElement_(tabId);
tabElement.tab = /** @type {!TabData} */ (
Object.assign({}, tabElement.tab, {groupId: groupId}));
this.placeTabElement(tabElement, index, false, groupId);
}
/**
* @param {string} groupId
* @param {!TabGroupVisualData} visualData
* @private
*/
onTabGroupVisualsChanged_(groupId, visualData) {
const tabGroupElement = this.findTabGroupElement_(groupId);
tabGroupElement.updateVisuals(visualData);
}
/**
* @param {number} tabId
* @param {number} newIndex
* @param {boolean} pinned
* @private
*/
onTabMoved_(tabId, newIndex, pinned) {
const movedTab = this.findTabElement_(tabId);
if (movedTab) {
this.placeTabElement(movedTab, newIndex, pinned, movedTab.tab.groupId);
if (movedTab.tab.active) {
this.scrollToTab_(movedTab);
}
}
}
/**
* @param {number} tabId
* @private
*/
onTabRemoved_(tabId) {
const tabElement = this.findTabElement_(tabId);
if (tabElement) {
this.addAnimationPromise_(tabElement.slideOut());
}
}
/**
* @param {number} oldId
* @param {number} newId
* @private
*/
onTabReplaced_(oldId, newId) {
const tabElement = this.findTabElement_(oldId);
if (!tabElement) {
return;
}
tabElement.tab = /** @type {!TabData} */ (
Object.assign({}, tabElement.tab, {id: newId}));
}
/**
* @param {!TabData} tab
* @private
*/
onTabUpdated_(tab) {
const tabElement = this.findTabElement_(tab.id);
if (!tabElement) {
return;
}
const previousTab = tabElement.tab;
tabElement.tab = tab;
if (previousTab.pinned !== tab.pinned) {
// If the tab is being pinned or unpinned, we need to move it to its new
// location
this.placeTabElement(tabElement, tab.index, tab.pinned, tab.groupId);
if (tab.active) {
this.scrollToTab_(tabElement);
}
this.updateThumbnailTrackStatus_(tabElement);
}
}
/** @private */
onWindowBlur_() {
if (this.shadowRoot.activeElement) {
// Blur the currently focused element when the window is blurred. This
// prevents the screen reader from momentarily reading out the
// previously focused element when the focus returns to this window.
this.shadowRoot.activeElement.blur();
}
}
/**
* @param {!Event} e
* @private
*/
onScroll_(e) {
this.clearScrollTimeout_();
this.scrollingTimeoutId_ = setTimeout(() => {
this.flushThumbnailTracker_();
this.clearScrollTimeout_();
}, 100);
}
/**
* @param {!Event} event
* @private
*/
onTouchStart_(event) {
const composedPath = /** @type {!Array<!Element>} */ (event.composedPath());
const dragOverTabElement =
/** @type {?TabElement} */ (composedPath.find(isTabElement));
this.lastTargetedTab_ = dragOverTabElement;
const touch = event.changedTouches[0];
this.lastTouchPoint_ = {clientX: touch.clientX, clientY: touch.clientY};
}
/** @private */
clearLastTargetedTab_() {
if (this.lastTargetedTab_) {
this.lastTargetedTab_.setTouchPressed(false);
}
this.lastTargetedTab_ = null;
this.lastTouchPoint_ = undefined;
}
/**
* @param {!TabElement} element
* @param {number} index
* @param {boolean} pinned
* @param {string=} groupId
*/
placeTabElement(element, index, pinned, groupId) {
const isInserting = !element.isConnected;
const previousIndex = isInserting ? -1 : this.getIndexOfTab(element);
const previousParent = element.parentElement;
this.updateTabElementDomPosition_(element, index, pinned, groupId);
if (!isInserting && previousParent === element.parentElement) {
// Only animate if the tab is being moved within the same parent. Tab
// moves that change pinned state or grouped states do not animate.
animateElementMoved(element, previousIndex, index);
}
if (isInserting) {
this.updateThumbnailTrackStatus_(element);
}
}
/**
* @param {!TabGroupElement} element
* @param {number} index
*/
placeTabGroupElement(element, index) {
const previousDomIndex =
Array.from(this.unpinnedTabsElement_.children).indexOf(element);
if (element.isConnected && element.childElementCount &&
this.getIndexOfTab(
/** @type {!TabElement} */ (element.firstElementChild)) < index) {
// If moving after its original position, the index value needs to be
// offset by 1 to consider itself already attached to the DOM.
index++;
}
let elementAtIndex = this.$all('tabstrip-tab')[index];
if (elementAtIndex && elementAtIndex.parentElement &&
isTabGroupElement(elementAtIndex.parentElement)) {
elementAtIndex = elementAtIndex.parentElement;
}
this.unpinnedTabsElement_.insertBefore(element, elementAtIndex);
// Animating the TabGroupElement move should be treated the same as
// animating a TabElement. Therefore, treat indices as if they were mere
// tabs and do not use the group's model index as they are not as accurate
// in representing DOM movements.
animateElementMoved(
element, previousDomIndex,
Array.from(this.unpinnedTabsElement_.children).indexOf(element));
}
/** @private */
flushThumbnailTracker_() {
this.thumbnailTracker_.forEach((shouldTrack, tabId) => {
this.tabsApi_.setThumbnailTracked(tabId, shouldTrack);
});
this.thumbnailTracker_.clear();
}
/** @private */
scrollToActiveTab_() {
const activeTab = this.getActiveTab_();
if (!activeTab) {
return;
}
this.scrollToTab_(activeTab);
}
/**
* @param {!TabElement} tabElement
* @private
*/
scrollToTab_(tabElement) {
const tabElementWidth = this.getLayoutVariable_(LayoutVariable.TAB_WIDTH);
const tabElementRect = tabElement.getBoundingClientRect();
// In RTL languages, the TabElement's scale animation scales from right to
// left. Therefore, the value of its getBoundingClientRect().left may not be
// accurate of its final rendered size because the element may not have
// fully scaled to the left yet.
const tabElementLeft =
isRTL() ? tabElementRect.right - tabElementWidth : tabElementRect.left;
const newTabButtonSpace =
this.getLayoutVariable_(LayoutVariable.NEW_TAB_BUTTON_WIDTH) +
this.getLayoutVariable_(LayoutVariable.NEW_TAB_BUTTON_MARGIN);
const leftBoundary =
isRTL() ? SCROLL_PADDING + newTabButtonSpace : SCROLL_PADDING;
let scrollBy = 0;
if (tabElementLeft === leftBoundary) {
// Perfectly aligned to the left.
return;
} else if (tabElementLeft < leftBoundary) {
// If the element's left is to the left of the left boundary, scroll
// such that the element's left edge is aligned with the left boundary.
scrollBy = tabElementLeft - leftBoundary;
} else {
const tabElementRight = tabElementLeft + tabElementWidth;
const rightBoundary = isRTL() ?
this.getLayoutVariable_(LayoutVariable.VIEWPORT_WIDTH) -
SCROLL_PADDING :
this.getLayoutVariable_(LayoutVariable.VIEWPORT_WIDTH) -
SCROLL_PADDING - newTabButtonSpace;
if (tabElementRight > rightBoundary) {
scrollBy = (tabElementRight) - rightBoundary;
} else {
// Perfectly aligned to the right.
return;
}
}
this.animateScrollPosition_(scrollBy);
}
/** @return {boolean} */
shouldPreventDrag() {
return this.$all('tabstrip-tab').length === 1;
}
/**
* @param {number} tabId
* @param {string} imgData
* @private
*/
tabThumbnailUpdated_(tabId, imgData) {
const tab = this.findTabElement_(tabId);
if (tab) {
tab.updateThumbnail(imgData);
}
}
/**
* @param {!TabElement} element
* @param {number} index
* @param {boolean} pinned
* @param {string=} groupId
* @private
*/
updateTabElementDomPosition_(element, index, pinned, groupId) {
// Remove the element if it already exists in the DOM. This simplifies
// the way indices work as it does not have to count its old index in
// the initial layout of the DOM.
element.remove();
if (pinned) {
this.pinnedTabsElement_.insertBefore(
element, this.pinnedTabsElement_.childNodes[index]);
} else {
let elementToInsert = element;
let elementAtIndex = this.$all('tabstrip-tab').item(index);
let parentElement = this.unpinnedTabsElement_;
if (groupId) {
let tabGroupElement = this.findTabGroupElement_(groupId);
if (tabGroupElement) {
// If a TabGroupElement already exists, add the TabElement to it.
parentElement = tabGroupElement;
} else {
// If a TabGroupElement does not exist, create one and add the
// TabGroupElement into the DOM.
tabGroupElement = document.createElement('tabstrip-tab-group');
tabGroupElement.setAttribute('data-group-id', groupId);
tabGroupElement.appendChild(element);
elementToInsert = tabGroupElement;
}
}
if (elementAtIndex && elementAtIndex.parentElement &&
isTabGroupElement(elementAtIndex.parentElement) &&
(elementAtIndex.previousElementSibling === null &&
elementAtIndex.tab.groupId !== groupId)) {
// If the element at the model index is in a group, and the group is
// different from the new tab's group, and is the first element in its
// group, insert the new element before its TabGroupElement. If a
// TabElement is being sandwiched between two TabElements in a group, it
// can be assumed that the tab will eventually be inserted into the
// group as well.
elementAtIndex = elementAtIndex.parentElement;
}
if (elementAtIndex && elementAtIndex.parentElement === parentElement) {
parentElement.insertBefore(elementToInsert, elementAtIndex);
} else {
parentElement.appendChild(elementToInsert);
}
}
}
/**
* @param {!TabElement} tabElement
* @private
*/
updateThumbnailTrackStatus_(tabElement) {
if (!tabElement.tab) {
return;
}
if (this.tabStripEmbedderProxy_.isVisible() && !tabElement.tab.pinned) {
// If the tab strip is visible and the tab is not pinned, let the
// IntersectionObserver start observing the TabElement to automatically
// determine if the tab's thumbnail should be tracked.
this.intersectionObserver_.observe(tabElement);
} else {
// If the tab strip is not visible or the tab is pinned, the tab does not
// need to show or update any thumbnails.
this.intersectionObserver_.unobserve(tabElement);
this.tabsApi_.setThumbnailTracked(tabElement.tab.id, false);
}
}
}
customElements.define('tabstrip-tab-list', TabListElement);