| // Copyright 2020 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 {assert} from 'chrome://resources/js/assert_ts.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js'; |
| |
| import {isTabElement, TabElement} from './tab.js'; |
| import {isDragHandle, isTabGroupElement, TabGroupElement} from './tab_group.js'; |
| import {Tab, TabNetworkState} from './tab_strip.mojom-webui.js'; |
| import {TabsApiProxy, TabsApiProxyImpl} from './tabs_api_proxy.js'; |
| |
| export const PLACEHOLDER_TAB_ID: number = -1; |
| |
| export const PLACEHOLDER_GROUP_ID: string = 'placeholder'; |
| |
| /** |
| * The data type key for pinned state of a tab. Since drag events only expose |
| * whether or not a data type exists (not the actual value), presence of this |
| * data type means that the tab is pinned. |
| */ |
| const PINNED_DATA_TYPE: string = 'pinned'; |
| |
| /** |
| * Gets the data type of tab IDs on DataTransfer objects in drag events. This |
| * is a function so that loadTimeData can get overridden by tests. |
| */ |
| function getTabIdDataType(): string { |
| return loadTimeData.getString('tabIdDataType'); |
| } |
| |
| function getGroupIdDataType(): string { |
| return loadTimeData.getString('tabGroupIdDataType'); |
| } |
| |
| function getDefaultTabData(): Tab { |
| return { |
| active: false, |
| alertStates: [], |
| blocked: false, |
| crashed: false, |
| id: -1, |
| index: -1, |
| isDefaultFavicon: false, |
| networkState: TabNetworkState.kNone, |
| pinned: false, |
| shouldHideThrobber: false, |
| showIcon: true, |
| title: '', |
| url: {url: ''}, |
| |
| // TODO(crbug.com/1293911): Remove once Mojo can produce proper TypeScript |
| // or TypeScript definitions, so that these properties are recognized as |
| // optional. |
| faviconUrl: undefined, |
| activeFaviconUrl: undefined, |
| groupId: undefined, |
| }; |
| } |
| |
| export interface DragManagerDelegate { |
| getIndexOfTab(tabElement: TabElement): number; |
| |
| placeTabElement( |
| element: TabElement, index: number, pinned: boolean, |
| groupId?: string): void; |
| |
| placeTabGroupElement(element: TabGroupElement, index: number): void; |
| |
| shouldPreventDrag(): boolean; |
| } |
| |
| type DragManagerDelegateElement = DragManagerDelegate&HTMLElement; |
| |
| class DragSession { |
| private delegate_: DragManagerDelegateElement; |
| private element_: TabElement|TabGroupElement; |
| private hasMoved_: boolean; |
| private lastPoint_: {x: number, y: number} = {x: 0, y: 0}; |
| |
| srcIndex: number; |
| srcGroup?: string; |
| |
| private tabsProxy_: TabsApiProxy = TabsApiProxyImpl.getInstance(); |
| |
| constructor( |
| delegate: DragManagerDelegateElement, element: TabElement|TabGroupElement, |
| srcIndex: number, srcGroup?: string) { |
| this.delegate_ = delegate; |
| this.element_ = element; |
| |
| /** |
| * Flag indicating if during the drag session, the element has at least |
| * moved once. |
| */ |
| this.hasMoved_ = false; |
| |
| this.srcIndex = srcIndex; |
| this.srcGroup = srcGroup; |
| } |
| |
| static createFromElement( |
| delegate: DragManagerDelegateElement, |
| element: TabElement|TabGroupElement): DragSession { |
| if (isTabGroupElement(element)) { |
| return new DragSession( |
| delegate, element, |
| delegate.getIndexOfTab(element.firstElementChild as TabElement)); |
| } |
| |
| const srcIndex = delegate.getIndexOfTab(element as TabElement); |
| const srcGroup = |
| (element.parentElement && isTabGroupElement(element.parentElement)) ? |
| element.parentElement.dataset.groupId : |
| undefined; |
| return new DragSession(delegate, element, srcIndex, srcGroup); |
| } |
| |
| static createFromEvent( |
| delegate: DragManagerDelegateElement, event: DragEvent): DragSession |
| |null { |
| if (event.dataTransfer!.types.includes(getTabIdDataType())) { |
| const isPinned = event.dataTransfer!.types.includes(PINNED_DATA_TYPE); |
| const placeholderTabElement = document.createElement('tabstrip-tab'); |
| placeholderTabElement.tab = Object.assign( |
| getDefaultTabData(), {id: PLACEHOLDER_TAB_ID, pinned: isPinned}); |
| placeholderTabElement.setDragging(true); |
| delegate.placeTabElement(placeholderTabElement, -1, isPinned); |
| return DragSession.createFromElement(delegate, placeholderTabElement); |
| } |
| |
| if (event.dataTransfer!.types.includes(getGroupIdDataType())) { |
| const placeholderGroupElement = |
| document.createElement('tabstrip-tab-group'); |
| placeholderGroupElement.dataset.groupId = PLACEHOLDER_GROUP_ID; |
| placeholderGroupElement.setDragging(true); |
| delegate.placeTabGroupElement(placeholderGroupElement, -1); |
| return DragSession.createFromElement(delegate, placeholderGroupElement); |
| } |
| |
| return null; |
| } |
| |
| get dstGroup(): string|undefined { |
| if (isTabElement(this.element_) && this.element_.parentElement && |
| isTabGroupElement(this.element_.parentElement)) { |
| return this.element_.parentElement.dataset.groupId; |
| } |
| |
| return undefined; |
| } |
| |
| get dstIndex(): number { |
| if (isTabElement(this.element_)) { |
| return this.delegate_.getIndexOfTab(this.element_ as TabElement); |
| } |
| |
| if (this.element_.children.length === 0) { |
| // If this group element has no children, it was a placeholder element |
| // being dragged. Find out the destination index by finding the index of |
| // the tab closest to it and incrementing it by 1. |
| const previousElement = this.element_.previousElementSibling; |
| if (!previousElement) { |
| return 0; |
| } |
| if (isTabElement(previousElement)) { |
| return this.delegate_.getIndexOfTab(previousElement as TabElement) + 1; |
| } |
| |
| assert(isTabGroupElement(previousElement)); |
| return this.delegate_.getIndexOfTab( |
| previousElement.lastElementChild as TabElement) + |
| 1; |
| } |
| |
| // If a tab group is moving backwards (to the front of the tab strip), the |
| // new index is the index of the first tab in that group. If a tab group is |
| // moving forwards (to the end of the tab strip), the new index is the index |
| // of the last tab in that group. |
| let dstIndex = this.delegate_.getIndexOfTab( |
| this.element_.firstElementChild as TabElement); |
| if (this.srcIndex <= dstIndex) { |
| dstIndex += this.element_.childElementCount - 1; |
| } |
| return dstIndex; |
| } |
| |
| cancel(event: DragEvent) { |
| if (this.isDraggingPlaceholder()) { |
| this.element_.remove(); |
| return; |
| } |
| |
| if (isTabGroupElement(this.element_)) { |
| this.delegate_.placeTabGroupElement( |
| this.element_ as TabGroupElement, this.srcIndex); |
| } else if (isTabElement(this.element_)) { |
| const tabElement = this.element_ as TabElement; |
| this.delegate_.placeTabElement( |
| tabElement, this.srcIndex, tabElement.tab.pinned, this.srcGroup); |
| } |
| |
| if (this.element_.isDraggedOut() && |
| event.dataTransfer!.dropEffect === 'move') { |
| // The element was dragged out of the current tab strip and was dropped |
| // into a new window. In this case, do not mark the element as no longer |
| // being dragged out. The element needs to be kept hidden, and will be |
| // automatically removed from the DOM with the next tab-removed event. |
| return; |
| } |
| |
| this.element_.setDragging(false); |
| this.element_.setDraggedOut(false); |
| } |
| |
| isDraggingPlaceholder(): boolean { |
| return this.isDraggingPlaceholderTab_() || |
| this.isDraggingPlaceholderGroup_(); |
| } |
| |
| private isDraggingPlaceholderTab_(): boolean { |
| return isTabElement(this.element_) && |
| (this.element_ as TabElement).tab.id === PLACEHOLDER_TAB_ID; |
| } |
| |
| private isDraggingPlaceholderGroup_(): boolean { |
| return isTabGroupElement(this.element_) && |
| this.element_.dataset.groupId === PLACEHOLDER_GROUP_ID; |
| } |
| |
| finish(event: DragEvent) { |
| const wasDraggingPlaceholder = this.isDraggingPlaceholderTab_(); |
| if (wasDraggingPlaceholder) { |
| const id = Number(event.dataTransfer!.getData(getTabIdDataType())); |
| (this.element_ as TabElement).tab = |
| Object.assign({}, (this.element_ as TabElement).tab, {id}); |
| } else if (this.isDraggingPlaceholderGroup_()) { |
| this.element_.dataset.groupId = |
| event.dataTransfer!.getData(getGroupIdDataType()); |
| } |
| |
| const dstIndex = this.dstIndex; |
| if (isTabElement(this.element_)) { |
| this.tabsProxy_.moveTab((this.element_ as TabElement).tab.id, dstIndex); |
| } else if (isTabGroupElement(this.element_)) { |
| this.tabsProxy_.moveGroup(this.element_.dataset.groupId!, dstIndex); |
| } |
| |
| const dstGroup = this.dstGroup; |
| if (dstGroup && dstGroup !== this.srcGroup) { |
| this.tabsProxy_.groupTab((this.element_ as TabElement).tab.id, dstGroup); |
| } else if (!dstGroup && this.srcGroup) { |
| this.tabsProxy_.ungroupTab((this.element_ as TabElement).tab.id); |
| } |
| |
| this.element_.setDragging(false); |
| this.element_.setDraggedOut(false); |
| } |
| |
| private shouldOffsetIndexForGroup_(dragOverElement: TabElement| |
| TabGroupElement): boolean { |
| // Since TabGroupElements do not have any TabElements, they need to offset |
| // the index for any elements that come after it as if there is at least |
| // one element inside of it. |
| return this.isDraggingPlaceholder() && |
| !!(dragOverElement.compareDocumentPosition(this.element_) & |
| Node.DOCUMENT_POSITION_PRECEDING); |
| } |
| |
| start(event: DragEvent) { |
| this.lastPoint_ = {x: event.clientX, y: event.clientY}; |
| event.dataTransfer!.effectAllowed = 'move'; |
| const draggedItemRect = |
| (event.composedPath()[0] as HTMLElement).getBoundingClientRect(); |
| this.element_.setDragging(true); |
| |
| const dragImage = this.element_.getDragImage(); |
| const dragImageRect = dragImage.getBoundingClientRect(); |
| |
| let scaleFactor = 1; |
| let verticalOffset = 0; |
| |
| // <if expr="chromeos_ash"> |
| // Touch on ChromeOS automatically scales drag images by 1.2 and adds a |
| // vertical offset of 25px. See //ash/drag_drop/drag_drop_controller.cc. |
| scaleFactor = 1.2; |
| verticalOffset = 25; |
| // </if> |
| |
| const eventXPercentage = |
| (event.clientX - draggedItemRect.left) / draggedItemRect.width; |
| const eventYPercentage = |
| (event.clientY - draggedItemRect.top) / draggedItemRect.height; |
| |
| // First, align the top-left corner of the drag image's center element |
| // to the event's coordinates. |
| const dragImageCenterRect = |
| this.element_.getDragImageCenter().getBoundingClientRect(); |
| let xOffset = (dragImageCenterRect.left - dragImageRect.left) * scaleFactor; |
| let yOffset = (dragImageCenterRect.top - dragImageRect.top) * scaleFactor; |
| |
| // Then, offset the drag image again by using the event's coordinates |
| // within the dragged item itself so that the drag image appears positioned |
| // as closely as its state before dragging. |
| xOffset += dragImageCenterRect.width * eventXPercentage; |
| yOffset += dragImageCenterRect.height * eventYPercentage; |
| yOffset -= verticalOffset; |
| |
| event.dataTransfer!.setDragImage(dragImage, xOffset, yOffset); |
| |
| if (isTabElement(this.element_)) { |
| const tabElement = this.element_ as TabElement; |
| event.dataTransfer!.setData( |
| getTabIdDataType(), tabElement.tab.id.toString()); |
| |
| if (tabElement.tab.pinned) { |
| event.dataTransfer!.setData( |
| PINNED_DATA_TYPE, tabElement.tab.pinned.toString()); |
| } |
| } else if (isTabGroupElement(this.element_)) { |
| event.dataTransfer!.setData( |
| getGroupIdDataType(), this.element_.dataset.groupId!); |
| } |
| } |
| |
| update(event: DragEvent) { |
| this.lastPoint_ = {x: event.clientX, y: event.clientY}; |
| |
| if (event.type === 'dragleave') { |
| this.element_.setDraggedOut(true); |
| this.hasMoved_ = true; |
| return; |
| } |
| |
| event.dataTransfer!.dropEffect = 'move'; |
| this.element_.setDraggedOut(false); |
| if (isTabGroupElement(this.element_)) { |
| this.updateForTabGroupElement_(event); |
| } else if (isTabElement(this.element_)) { |
| this.updateForTabElement_(event); |
| } |
| } |
| |
| private updateForTabGroupElement_(event: DragEvent) { |
| const tabGroupElement = this.element_ as TabGroupElement; |
| const composedPath = event.composedPath() as Element[]; |
| if (composedPath.includes(this.element_)) { |
| // Dragging over itself or a child of itself. |
| return; |
| } |
| |
| const dragOverTabElement = |
| composedPath.find(isTabElement) as TabElement | undefined; |
| if (dragOverTabElement && !dragOverTabElement.tab.pinned && |
| dragOverTabElement.isValidDragOverTarget) { |
| let dragOverIndex = this.delegate_.getIndexOfTab(dragOverTabElement); |
| dragOverIndex += |
| this.shouldOffsetIndexForGroup_(dragOverTabElement) ? 1 : 0; |
| this.delegate_.placeTabGroupElement(tabGroupElement, dragOverIndex); |
| this.hasMoved_ = true; |
| return; |
| } |
| |
| const dragOverGroupElement = |
| composedPath.find(isTabGroupElement) as TabGroupElement | undefined; |
| if (dragOverGroupElement && dragOverGroupElement.isValidDragOverTarget) { |
| let dragOverIndex = this.delegate_.getIndexOfTab( |
| dragOverGroupElement.firstElementChild as TabElement); |
| dragOverIndex += |
| this.shouldOffsetIndexForGroup_(dragOverGroupElement) ? 1 : 0; |
| this.delegate_.placeTabGroupElement(tabGroupElement, dragOverIndex); |
| this.hasMoved_ = true; |
| } |
| } |
| |
| private updateForTabElement_(event: DragEvent) { |
| const tabElement = this.element_ as TabElement; |
| const composedPath = event.composedPath() as Element[]; |
| const dragOverTabElement = |
| composedPath.find(isTabElement) as TabElement | undefined; |
| if (dragOverTabElement && |
| (dragOverTabElement.tab.pinned !== tabElement.tab.pinned || |
| !dragOverTabElement.isValidDragOverTarget)) { |
| // Can only drag between the same pinned states and valid TabElements. |
| return; |
| } |
| |
| const previousGroupId = (tabElement.parentElement && |
| isTabGroupElement(tabElement.parentElement)) ? |
| tabElement.parentElement.dataset.groupId : |
| undefined; |
| |
| const dragOverTabGroup = |
| composedPath.find(isTabGroupElement) as TabGroupElement | undefined; |
| if (dragOverTabGroup && |
| dragOverTabGroup.dataset.groupId !== previousGroupId && |
| dragOverTabGroup.isValidDragOverTarget) { |
| this.delegate_.placeTabElement( |
| tabElement, this.dstIndex, false, dragOverTabGroup.dataset.groupId); |
| this.hasMoved_ = true; |
| return; |
| } |
| |
| if (!dragOverTabGroup && previousGroupId) { |
| this.delegate_.placeTabElement( |
| tabElement, this.dstIndex, false, undefined); |
| this.hasMoved_ = true; |
| return; |
| } |
| |
| if (!dragOverTabElement) { |
| return; |
| } |
| |
| const dragOverIndex = this.delegate_.getIndexOfTab(dragOverTabElement); |
| this.delegate_.placeTabElement( |
| tabElement, dragOverIndex, tabElement.tab.pinned, previousGroupId); |
| this.hasMoved_ = true; |
| } |
| } |
| |
| export class DragManager { |
| private delegate_: DragManagerDelegateElement; |
| private dragSession_: DragSession|null = null; |
| private tabsProxy_: TabsApiProxy = TabsApiProxyImpl.getInstance(); |
| |
| constructor(delegate: DragManagerDelegateElement) { |
| this.delegate_ = delegate; |
| } |
| |
| private onDragLeave_(event: DragEvent) { |
| if (this.dragSession_ && this.dragSession_.isDraggingPlaceholder()) { |
| this.dragSession_.cancel(event); |
| this.dragSession_ = null; |
| return; |
| } |
| |
| this.dragSession_!.update(event); |
| } |
| |
| private onDragOver_(event: DragEvent) { |
| event.preventDefault(); |
| if (!this.dragSession_) { |
| return; |
| } |
| |
| this.dragSession_.update(event); |
| } |
| |
| private onDragStart_(event: DragEvent) { |
| const composedPath = event.composedPath() as Element[]; |
| const draggedItem = composedPath.find(item => { |
| return isTabElement(item) || isTabGroupElement(item); |
| }); |
| if (!draggedItem) { |
| return; |
| } |
| |
| // If we are dragging a tab or tab group element ensure its touch pressed |
| // state is reset to avoid any associated css effects making it onto the |
| // drag image. |
| if (isTabElement(draggedItem) || isTabGroupElement(draggedItem)) { |
| (draggedItem as TabElement | TabGroupElement).setTouchPressed(false); |
| } |
| |
| // Make sure drag handle is under touch point when dragging a tab group. |
| if (isTabGroupElement(draggedItem) && !composedPath.find(isDragHandle)) { |
| return; |
| } |
| |
| if (this.delegate_.shouldPreventDrag()) { |
| event.preventDefault(); |
| return; |
| } |
| |
| this.dragSession_ = DragSession.createFromElement( |
| this.delegate_, draggedItem as TabElement | TabGroupElement); |
| this.dragSession_.start(event); |
| } |
| |
| private onDragEnd_(event: DragEvent) { |
| if (!this.dragSession_) { |
| return; |
| } |
| |
| this.dragSession_.cancel(event); |
| this.dragSession_ = null; |
| } |
| |
| private onDragEnter_(event: DragEvent) { |
| if (this.dragSession_) { |
| // TODO(crbug.com/843556): Do not update the drag session on dragenter. |
| // An incorrect event target on dragenter causes tabs to move around |
| // erroneously. |
| return; |
| } |
| |
| this.dragSession_ = DragSession.createFromEvent(this.delegate_, event); |
| } |
| |
| private onDrop_(event: DragEvent) { |
| if (!this.dragSession_) { |
| return; |
| } |
| |
| this.dragSession_.finish(event); |
| this.dragSession_ = null; |
| } |
| |
| startObserving() { |
| this.delegate_.addEventListener('dragstart', e => this.onDragStart_(e)); |
| this.delegate_.addEventListener('dragend', e => this.onDragEnd_(e)); |
| this.delegate_.addEventListener('dragenter', e => this.onDragEnter_(e)); |
| this.delegate_.addEventListener('dragleave', e => this.onDragLeave_(e)); |
| this.delegate_.addEventListener('dragover', e => this.onDragOver_(e)); |
| this.delegate_.addEventListener('drop', e => this.onDrop_(e)); |
| } |
| } |