chromium/chrome/browser/resources/tab_strip/drag_manager.ts

// Copyright 2020 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 {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import type {TabElement} from './tab.js';
import {isTabElement} from './tab.js';
import type {TabGroupElement} from './tab_group.js';
import {isDragHandle, isTabGroupElement} from './tab_group.js';
import type {Tab} from './tab_strip.mojom-webui.js';
import {TabNetworkState} from './tab_strip.mojom-webui.js';
import type {TabsApiProxy} from './tabs_api_proxy.js';
import {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: ''},
    faviconUrl: null,
    activeFaviconUrl: null,
    groupId: null,
  };
}

export interface DragManagerDelegate {
  getIndexOfTab(tabElement: TabElement): number;

  placeTabElement(
      element: TabElement, index: number, pinned: boolean,
      groupId: string|null): void;

  placeTabGroupElement(element: TabGroupElement, index: number): void;

  shouldPreventDrag(isDraggingTab: boolean): boolean;
}

type DragManagerDelegateElement = DragManagerDelegate&HTMLElement;

class DragSession {
  private delegate_: DragManagerDelegateElement;
  private element_: TabElement|TabGroupElement;

  srcIndex: number;
  srcGroup: string|null;

  private tabsProxy_: TabsApiProxy = TabsApiProxyImpl.getInstance();

  constructor(
      delegate: DragManagerDelegateElement, element: TabElement|TabGroupElement,
      srcIndex: number, srcGroup: string|null) {
    this.delegate_ = delegate;
    this.element_ = element;

    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),
          null);
    }

    const srcIndex = delegate.getIndexOfTab(element as TabElement);
    const srcGroup =
        (element.parentElement && isTabGroupElement(element.parentElement)) ?
        element.parentElement.dataset['groupId']! :
        null;
    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, null);
      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;
    }

    const dstIndex = this.delegate_.getIndexOfTab(
        this.element_.firstElementChild as TabElement);

    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) {
    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) {
    if (event.type === 'dragleave') {
      this.element_.setDraggedOut(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);
      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);
    }
  }

  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']! :
        null;

    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'] || null);
      return;
    }

    if (!dragOverTabGroup && previousGroupId) {
      this.delegate_.placeTabElement(tabElement, this.dstIndex, false, null);
      return;
    }

    if (!dragOverTabElement) {
      return;
    }

    const dragOverIndex = this.delegate_.getIndexOfTab(dragOverTabElement);
    this.delegate_.placeTabElement(
        tabElement, dragOverIndex, tabElement.tab.pinned, previousGroupId);
  }
}

export class DragManager {
  private delegate_: DragManagerDelegateElement;
  private dragSession_: DragSession|null = null;

  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(isTabElement(draggedItem))) {
      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/41389308): 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));
  }
}