// Copyright 2019 The Chromium Authors
// 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 './tab_group.js';
import {ColorChangeUpdater} from 'chrome://resources/cr_components/color_change_listener/colors_css_updater.js';
import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {isRTL} from 'chrome://resources/js/util.js';
import type {DragManagerDelegate} from './drag_manager.js';
import {DragManager} from './drag_manager.js';
import {isTabElement, TabElement} from './tab.js';
import type {TabGroupElement} from './tab_group.js';
import {isDragHandle, isTabGroupElement} from './tab_group.js';
import {getTemplate} from './tab_list.html.js';
import type {Tab, TabGroupVisualData} from './tab_strip.mojom-webui.js';
import type {TabsApiProxy} from './tabs_api_proxy.js';
import {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 SCROLL_PADDING: number = 32;
let scrollAnimationEnabled: boolean = true;
const TOUCH_CONTEXT_MENU_OFFSET_X: number = 8;
const TOUCH_CONTEXT_MENU_OFFSET_Y: number = -40;
/**
* Context menu should position below the element for touch.
*/
function getContextMenuPosition(element: Element): {x: number, y: number} {
const rect = element.getBoundingClientRect();
return {
x: rect.left + TOUCH_CONTEXT_MENU_OFFSET_X,
y: rect.bottom + TOUCH_CONTEXT_MENU_OFFSET_Y,
};
}
export function setScrollAnimationEnabledForTesting(enabled: boolean) {
scrollAnimationEnabled = enabled;
}
enum LayoutVariable {
VIEWPORT_WIDTH = '--tabstrip-viewport-width',
TAB_WIDTH = '--tabstrip-tab-thumbnail-width',
}
/**
* Animates a series of elements to indicate that tabs have moved position.
*/
function animateElementMoved(
movedElement: Element, prevIndex: number, newIndex: number) {
// 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);
function getSiblingToAnimate(element: Element): Element|null {
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).
*/
function slideElement(element: Element, prevIndex: number, newIndex: number) {
let horizontalMovement = newIndex - prevIndex;
let verticalMovement = 0;
if (isTabElement(element) && (element as TabElement).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 as TabElement | TabGroupElement).isValidDragOverTarget = false;
const animation = element.animate(
[
{transform: `translate(${translateX}, ${translateY})`},
{transform: 'translate(0, 0)'},
],
{
duration: 120,
easing: 'ease-out',
});
function onComplete() {
(element as TabElement | TabGroupElement).isValidDragOverTarget = true;
}
animation.oncancel = onComplete;
animation.onfinish = onComplete;
}
export class TabListElement extends CustomElement implements
DragManagerDelegate {
animationPromises: Promise<void>;
private currentScrollUpdateFrame_: number|null;
private draggedItem_?: TabElement|TabGroupElement;
private dropPlaceholder_: HTMLElement;
private focusOutlineManager_: FocusOutlineManager;
private thumbnailTracker_: Map<number, boolean>;
private intersectionObserver_: IntersectionObserver;
private activatingTabId_?: number;
private activatingTabIdTimestamp_?: number; // In ms.
private eventTracker_: EventTracker;
private lastTargetedItem_: TabElement|TabGroupElement|null = null;
private lastTouchPoint_?: {clientX: number, clientY: number};
private pinnedTabsElement_: Element;
private tabsApi_: TabsApiProxy;
private unpinnedTabsElement_: Element;
private scrollingTimeoutId_: number;
static override get template() {
return getTemplate();
}
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.
*/
this.animationPromises = Promise.resolve();
/**
* The ID of the current animation frame that is in queue to update the
* scroll position.
*/
this.currentScrollUpdateFrame_ = null;
/**
* The element that is currently being dragged.
*/
this.draggedItem_;
this.dropPlaceholder_ = document.createElement('div');
this.dropPlaceholder_.id = 'dropPlaceholder';
this.focusOutlineManager_ = FocusOutlineManager.forDocument(document);
/**
* Map of tab IDs to whether or not the tab's thumbnail should be tracked.
*/
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.
*/
this.intersectionObserver_ = new IntersectionObserver(entries => {
for (const entry of entries) {
this.thumbnailTracker_.set(
(entry.target as TabElement).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%',
});
this.eventTracker_ = new EventTracker();
this.pinnedTabsElement_ = this.getRequiredElement('#pinnedTabs');
this.tabsApi_ = TabsApiProxyImpl.getInstance();
this.unpinnedTabsElement_ = this.getRequiredElement('#unpinnedTabs');
/**
* 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.
*/
this.scrollingTimeoutId_ = -1;
const callbackRouter = this.tabsApi_.getCallbackRouter();
callbackRouter.layoutChanged.addListener(
this.applyCssDictionary_.bind(this));
callbackRouter.tabThumbnailUpdated.addListener(
this.tabThumbnailUpdated_.bind(this));
callbackRouter.longPress.addListener(() => this.handleLongPress_());
callbackRouter.contextMenuClosed.addListener(
() => this.clearLastTargetedItem_());
callbackRouter.receivedKeyboardFocus.addListener(
() => this.onReceivedKeyboardFocus_());
callbackRouter.themeChanged.addListener(() => {
// Refetch theme group color and tab favicons on theme change.
this.fetchAndUpdateGroupData_();
this.fetchAndUpdateTabs_();
});
this.eventTracker_.add(
document, 'contextmenu', (e: Event) => this.onContextMenu_(e));
this.eventTracker_.add(
document, 'pointerup',
(e: Event) => this.onPointerUp_(e as PointerEvent));
this.eventTracker_.add(
document, 'visibilitychange', () => this.onDocumentVisibilityChange_());
this.eventTracker_.add(window, 'blur', () => this.onWindowBlur_());
this.eventTracker_.add(this, 'scroll', (e: Event) => this.onScroll_(e));
this.eventTracker_.add(
document, 'touchstart',
(e: Event) => this.onTouchStart_(e as TouchEvent));
// Touchmove events happen when a user has started a touch gesture sequence
// and proceeded to move their touch pointer across the screen. Ensure that
// we clear the `last_targeted_item_` in these cases to ensure the pressed
// visual is cleared away.
this.eventTracker_.add(
document, 'touchmove', () => this.clearLastTargetedItem_());
const dragManager = new DragManager(this);
dragManager.startObserving();
ColorChangeUpdater.forDocument().start();
}
private addAnimationPromise_(promise: Promise<void>) {
this.animationPromises = this.animationPromises.then(() => promise);
}
private animateScrollPosition_(scrollBy: number) {
if (this.currentScrollUpdateFrame_) {
cancelAnimationFrame(this.currentScrollUpdateFrame_);
this.currentScrollUpdateFrame_ = null;
}
const prevScrollLeft = this.scrollLeft;
if (!scrollAnimationEnabled || !this.tabsApi_.isVisible()) {
// Do not animate if tab strip is not visible.
this.scrollLeft = prevScrollLeft + scrollBy;
return;
}
const duration = 350;
let startTime: number;
const onAnimationFrame = (currentTime: number) => {
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);
}
private applyCssDictionary_(dictionary: {[key: string]: string}) {
for (const [cssVariable, value] of Object.entries(dictionary)) {
this.style.setProperty(cssVariable, value);
}
}
private clearScrollTimeout_() {
clearTimeout(this.scrollingTimeoutId_);
this.scrollingTimeoutId_ = -1;
}
connectedCallback() {
this.tabsApi_.getLayout().then(
({layout}) => this.applyCssDictionary_(layout));
const getTabsStartTimestamp = Date.now();
this.tabsApi_.getTabs().then(({tabs}) => {
this.tabsApi_.reportTabDataReceivedDuration(
tabs.length, Date.now() - getTabsStartTimestamp);
const createTabsStartTimestamp = Date.now();
tabs.forEach(tab => this.onTabCreated_(tab));
this.fetchAndUpdateGroupData_();
this.tabsApi_.reportTabCreationDuration(
tabs.length, Date.now() - createTabsStartTimestamp);
const callbackRouter = this.tabsApi_.getCallbackRouter();
callbackRouter.showContextMenu.addListener(
() => this.onShowContextMenu_());
callbackRouter.tabCreated.addListener(this.onTabCreated_.bind(this));
callbackRouter.tabMoved.addListener(this.onTabMoved_.bind(this));
callbackRouter.tabRemoved.addListener(this.onTabRemoved_.bind(this));
callbackRouter.tabReplaced.addListener(this.onTabReplaced_.bind(this));
callbackRouter.tabUpdated.addListener(this.onTabUpdated_.bind(this));
callbackRouter.tabActiveChanged.addListener(
this.onTabActivated_.bind(this));
callbackRouter.tabCloseCancelled.addListener(
this.onTabCloseCancelled_.bind(this));
callbackRouter.tabGroupStateChanged.addListener(
this.onTabGroupStateChanged_.bind(this));
callbackRouter.tabGroupClosed.addListener(
this.onTabGroupClosed_.bind(this));
callbackRouter.tabGroupMoved.addListener(
this.onTabGroupMoved_.bind(this));
callbackRouter.tabGroupVisualsChanged.addListener(
this.onTabGroupVisualsChanged_.bind(this));
});
}
disconnectedCallback() {
this.eventTracker_.removeAll();
}
private createTabElement_(tab: Tab): TabElement {
const tabElement = new TabElement();
tabElement.tab = tab;
tabElement.onTabActivating = (id) => {
this.onTabActivating_(id);
};
return tabElement;
}
private findTabElement_(tabId: number): TabElement|null {
return this.$<TabElement>(`tabstrip-tab[data-tab-id="${tabId}"]`);
}
private findTabGroupElement_(groupId: string): TabGroupElement|null {
return this.$<TabGroupElement>(
`tabstrip-tab-group[data-group-id="${groupId}"]`);
}
private fetchAndUpdateGroupData_() {
const tabGroupElements = this.$all('tabstrip-tab-group');
this.tabsApi_.getGroupVisualData().then(({data}) => {
tabGroupElements.forEach(tabGroupElement => {
const visualData = data[tabGroupElement.dataset['groupId']!];
assert(visualData);
tabGroupElement.updateVisuals(visualData);
});
});
}
private fetchAndUpdateTabs_() {
this.tabsApi_.getTabs().then(({tabs}) => {
tabs.forEach(tab => this.onTabUpdated_(tab));
});
}
private getActiveTab_(): TabElement|null {
return this.$<TabElement>('tabstrip-tab[active]');
}
getIndexOfTab(tabElement: TabElement): number {
return Array.prototype.indexOf.call(this.$all('tabstrip-tab'), tabElement);
}
/** @return in pixels */
private getLayoutVariable_(variable: LayoutVariable): number {
return parseInt(this.style.getPropertyValue(variable), 10);
}
private handleLongPress_() {
if (this.lastTargetedItem_) {
this.lastTargetedItem_.setTouchPressed(true);
}
}
private onContextMenu_(event: Event) {
// Prevent the default context menu from triggering.
event.preventDefault();
}
private onPointerUp_(event: PointerEvent) {
event.stopPropagation();
if (event.pointerType !== 'touch' && event.button === 2) {
// If processing an uncaught right click event show the background context
// menu.
this.tabsApi_.showBackgroundContextMenu(event.clientX, event.clientY);
}
}
private onDocumentVisibilityChange_() {
if (!this.tabsApi_.isVisible()) {
this.scrollToActiveTab_();
}
this.unpinnedTabsElement_.childNodes.forEach(element => {
if (isTabGroupElement(element as Element)) {
element.childNodes.forEach(
tabElement =>
this.updateThumbnailTrackStatus_(tabElement as TabElement));
} else {
this.updateThumbnailTrackStatus_(element as TabElement);
}
});
}
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.$<TabElement>('tabstrip-tab')!.focus();
}
private updatePreviouslyActiveTabs_(activeTabId: number) {
// 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<TabElement>('tabstrip-tab[active]')
.forEach((previouslyActiveTab) => {
if (previouslyActiveTab.tab.id !== activeTabId) {
previouslyActiveTab.tab = /** @type {!Tab} */ (
Object.assign({}, previouslyActiveTab.tab, {active: false}));
}
});
}
private onTabActivated_(tabId: number) {
if (this.activatingTabId_ === tabId) {
this.tabsApi_.reportTabActivationDuration(
Date.now() - this.activatingTabIdTimestamp_!);
}
this.activatingTabId_ = undefined;
this.activatingTabIdTimestamp_ = undefined;
this.updatePreviouslyActiveTabs_(tabId);
const newlyActiveTab = this.findTabElement_(tabId);
if (newlyActiveTab) {
newlyActiveTab.tab =
Object.assign({}, newlyActiveTab.tab, {active: true});
if (!this.tabsApi_.isVisible()) {
this.scrollToTab_(newlyActiveTab);
}
}
}
private onTabActivating_(id: number) {
// onTabActivating_() is called when the user clicks on a tab in JavaScript.
// We then expect a callback asynchronously from the browser after the tab
// we clicked on has finally activated. We may incur multiple calls to
// onTabActivating_() before the active tab actually changes so we only
// consider the most recent activating action when recording metrics. (See
// crbug.com/1333405)
const activeTab = this.getActiveTab_();
if (activeTab && activeTab.tab.id === id) {
return;
}
this.activatingTabId_ = id;
this.activatingTabIdTimestamp_ = Date.now();
}
private onTabCloseCancelled_(id: number) {
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.lastTargetedItem_ && isTabElement(this.lastTargetedItem_)) {
const position = getContextMenuPosition(this.lastTargetedItem_);
this.tabsApi_.showTabContextMenu(
(this.lastTargetedItem_ as TabElement).tab.id, position.x,
position.y);
} else {
this.tabsApi_.showBackgroundContextMenu(
this.lastTouchPoint_.clientX, this.lastTouchPoint_.clientY);
}
}
private onTabCreated_(tab: 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);
if (tab.active) {
this.updatePreviouslyActiveTabs_(tab.id);
this.scrollToTab_(tabElement);
}
}
private onTabGroupClosed_(groupId: string) {
const tabGroupElement = this.findTabGroupElement_(groupId);
if (!tabGroupElement) {
return;
}
tabGroupElement.remove();
}
private onTabGroupMoved_(groupId: string, index: number) {
const tabGroupElement = this.findTabGroupElement_(groupId);
if (!tabGroupElement) {
return;
}
this.placeTabGroupElement(tabGroupElement, index);
}
private onTabGroupStateChanged_(
tabId: number, index: number, groupId: string) {
const tabElement = this.findTabElement_(tabId)!;
tabElement.tab = Object.assign({}, tabElement.tab, {groupId: groupId});
this.placeTabElement(tabElement, index, false, groupId);
}
private onTabGroupVisualsChanged_(
groupId: string, visualData: TabGroupVisualData) {
const tabGroupElement = this.findTabGroupElement_(groupId)!;
tabGroupElement.updateVisuals(visualData);
}
private onTabMoved_(tabId: number, newIndex: number, pinned: boolean) {
const movedTab = this.findTabElement_(tabId);
if (movedTab) {
this.placeTabElement(movedTab, newIndex, pinned, movedTab.tab.groupId);
if (movedTab.tab.active) {
this.scrollToTab_(movedTab);
}
}
}
private onTabRemoved_(tabId: number) {
const tabElement = this.findTabElement_(tabId);
if (tabElement) {
this.addAnimationPromise_(tabElement.slideOut());
}
}
private onTabReplaced_(oldId: number, newId: number) {
const tabElement = this.findTabElement_(oldId);
if (!tabElement) {
return;
}
tabElement.tab = Object.assign({}, tabElement.tab, {id: newId});
}
private onTabUpdated_(tab: 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 as HTMLElement).blur();
}
}
private onScroll_(_e: Event) {
this.clearScrollTimeout_();
this.scrollingTimeoutId_ = setTimeout(() => {
this.flushThumbnailTracker_();
this.clearScrollTimeout_();
}, 100);
}
private onTouchStart_(event: TouchEvent) {
const composedPath = event.composedPath() as Element[];
const dragOverTabElement =
(composedPath.find(isTabElement) ||
composedPath.find(isTabGroupElement) || null) as TabElement |
TabGroupElement | null;
// Make sure drag handle is under touch point when dragging a tab group.
if (dragOverTabElement && isTabGroupElement(dragOverTabElement) &&
!composedPath.find(isDragHandle)) {
return;
}
this.lastTargetedItem_ = dragOverTabElement;
const touch = event.changedTouches[0]!;
this.lastTouchPoint_ = {clientX: touch.clientX, clientY: touch.clientY};
}
private clearLastTargetedItem_() {
if (this.lastTargetedItem_) {
this.lastTargetedItem_.setTouchPressed(false);
}
this.lastTargetedItem_ = null;
this.lastTouchPoint_ = undefined;
}
placeTabElement(
element: TabElement, index: number, pinned: boolean,
groupId: string|null) {
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);
}
}
placeTabGroupElement(element: TabGroupElement, index: number) {
const previousDomIndex =
Array.from(this.unpinnedTabsElement_.children).indexOf(element);
if (element.isConnected && element.childElementCount &&
this.getIndexOfTab(element.firstElementChild as TabElement) < 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: TabGroupElement|TabElement =
this.$all('tabstrip-tab')[index]!;
if (elementAtIndex && elementAtIndex.parentElement &&
isTabGroupElement(elementAtIndex.parentElement)) {
elementAtIndex = elementAtIndex.parentElement as TabGroupElement;
}
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);
}
private scrollToTab_(tabElement: 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 leftBoundary = 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 =
this.getLayoutVariable_(LayoutVariable.VIEWPORT_WIDTH) -
SCROLL_PADDING;
if (tabElementRight > rightBoundary) {
scrollBy = (tabElementRight) - rightBoundary;
} else {
// Perfectly aligned to the right.
return;
}
}
this.animateScrollPosition_(scrollBy);
}
shouldPreventDrag(isDraggingTab: boolean): boolean {
if (isDraggingTab) {
// Do not allow dragging a tab if there's only 1 tab.
return this.$all('tabstrip-tab').length === 1;
} else {
// Do not allow dragging the tab group with no others outside of the tab
// group. In this case there is only 1 pinned and unpinned top level
// element, which is the dragging tab group itself.
return (this.pinnedTabsElement_.childElementCount +
this.unpinnedTabsElement_.childElementCount) === 1;
}
}
private tabThumbnailUpdated_(tabId: number, imgData: string) {
const tab = this.findTabElement_(tabId);
if (tab) {
tab.updateThumbnail(imgData);
}
}
private updateTabElementDomPosition_(
element: TabElement, index: number, pinned: boolean,
groupId: string|null) {
// 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: TabElement|TabGroupElement = element;
let elementAtIndex: TabElement|TabGroupElement =
this.$all<TabElement>('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 as TabGroupElement;
}
if (elementAtIndex && elementAtIndex.parentElement === parentElement) {
parentElement.insertBefore(elementToInsert, elementAtIndex);
} else {
parentElement.appendChild(elementToInsert);
}
}
}
private updateThumbnailTrackStatus_(tabElement: TabElement) {
if (!tabElement.hasTabModel()) {
return;
}
if (this.tabsApi_.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);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'tabstrip-tab-list': TabListElement;
}
}
customElements.define('tabstrip-tab-list', TabListElement);