chromium/chrome/browser/resources/ash/settings/device_page/drag_and_drop_manager.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview
 * DragAndDropManager is a class that handles the drag + drop implementation
 * of CustomizeButtonsSubsection and CustomizeButtonRow. Specifically, it
 * handles management of the drag indicator (the line between rows when an item
 * is being dragged over), calculates the drop position, and invokes a callback
 * when an eligible element is dropped.
 */

import {EventTracker} from 'chrome://resources/js/event_tracker.js';

import {CustomizeButtonRowElement} from './customize_button_row.js';

enum DropPosition {
  ABOVE = 0,
  BELOW = 1,
}

interface DropDestination {
  element: CustomizeButtonRowElement;
  position: DropPosition;
}

export interface OnDropCallback {
  (originIndex: number, destinationIndex: number): void;
}

const DATA_TRANSFER_KEY = 'settings-data';

/**
 * Mutates the given DragEvent to set data on its dataTransfer property.
 * @param dragEvent The DragEvent to mutate.
 * @param originIndex The index of the row from which the drop originated.
 */
export function setDataTransferOriginIndex(
    dragEvent: DragEvent, originIndex: number): void {
  if (dragEvent.dataTransfer) {
    dragEvent.dataTransfer.setData(
        DATA_TRANSFER_KEY, JSON.stringify({originIndex}));
  }
}

/**
 * Retrieves data from the given DragEvent using its dataTransfer property.
 * @param dragEvent The DragEvent to retrieve data from.
 */
export function getDataTransferOriginIndex(dragEvent: DragEvent): number|null {
  if (!dragEvent.dataTransfer) {
    return null;
  }
  const settingsDataRaw = dragEvent.dataTransfer.getData(DATA_TRANSFER_KEY);
  if (!settingsDataRaw) {
    return null;
  }

  // Use a try/catch in case the JSON parse fails.
  try {
    const settingsData = JSON.parse(settingsDataRaw);
    if (settingsData.originIndex === undefined) {
      return null;
    }
    return settingsData.originIndex;
  } catch (err) {
    return null;
  }
}

export class DragAndDropManager {
  private eventTracker_: EventTracker = new EventTracker();
  private dropDestination: DropDestination|null;
  private dropElement: CustomizeButtonRowElement|null;
  private onDropCallback: OnDropCallback;

  init(element: HTMLElement, onDropCallbackParam: OnDropCallback): void {
    this.eventTracker_.add(
        element, 'dragover', (e: DragEvent) => this.onDragOver_(e));
    this.eventTracker_.add(element, 'dragleave', () => this.onDragLeave_());
    this.eventTracker_.add(element, 'drop', (e: DragEvent) => this.onDrop_(e));
    this.onDropCallback = onDropCallbackParam;
  }

  destroy(): void {
    this.clearDropIndicator_();
    this.eventTracker_.removeAll();
  }

  private onDragOver_(event: DragEvent): void {
    // Preventing default allows the drop event to work later.
    // See MDN docs http://go/mdn/API/HTMLElement/drop_event for more info.
    event.preventDefault();

    this.clearDropIndicator_();

    // Get the element that is being dragged over.
    const overElement = this.getButtonRowElement_(event.composedPath());
    if (!overElement) {
      return;
    }

    this.dropDestination =
        this.calculateDropDestination_(event.clientY, overElement);
    if (!this.dropDestination) {
      this.dropElement = null;
      return;
    }

    this.dropElement = this.dropDestination.element;

    if (this.dropDestination.position === DropPosition.ABOVE) {
      this.dropElement.classList.add('drop-indicator-top');
    } else {
      this.dropElement.classList.add('drop-indicator-bottom');
    }
  }

  private clearDropIndicator_(): void {
    if (this.dropElement) {
      this.dropElement.classList.remove('drop-indicator-top');
      this.dropElement.classList.remove('drop-indicator-bottom');
    }
  }

  private onDragLeave_(): void {
    this.clearDropIndicator_();
  }

  private onDrop_(event: DragEvent): void {
    this.clearDropIndicator_();

    const originIndex = getDataTransferOriginIndex(event);
    if (originIndex === null || !this.dropDestination) {
      return;
    }

    let destinationElementIndex = this.dropDestination.element.remappingIndex;

    // Before modifying the destination index, check if the element is
    // being dropped into the same position.
    if (originIndex === destinationElementIndex) {
      return;
    }

    if (originIndex < destinationElementIndex) {
      // If originIndex is before destinationElementIndex, shift the destination
      // index up by one to account for the origin element being removed.
      destinationElementIndex -= 1;
    }

    if (this.dropDestination.position === DropPosition.BELOW) {
      destinationElementIndex += 1;
    }

    // After resolving the destination index, is the element ending up in the
    // same position?
    if (originIndex === destinationElementIndex) {
      // Element was dropped into the same position.
      return;
    }

    this.onDropCallback(originIndex, destinationElementIndex);
  }

  /**
   * Given a path (from event.composedPath()), this function returns the
   * CustomizeButtonRowElement that was targeted.
   */
  private getButtonRowElement_(path?: EventTarget[]): CustomizeButtonRowElement
      |null {
    if (!path) {
      return null;
    }

    for (let i = 0; i < path.length; i++) {
      const element = path[i] as Element;
      if (element.tagName === CustomizeButtonRowElement.is.toUpperCase()) {
        return path[i] as CustomizeButtonRowElement;
      }
    }
    return null;
  }

  /**
   * This function determines where the drop should occur by using the mouse
   * position during the drag.
   */
  private calculateDropDestination_(
      elementClientY: number,
      overElement: CustomizeButtonRowElement): DropDestination {
    const rect = overElement.getBoundingClientRect();
    const yRatio = (elementClientY - rect.top) / rect.height;

    if (yRatio <= .5) {
      return {element: overElement, position: DropPosition.ABOVE};
    } else {
      return {element: overElement, position: DropPosition.BELOW};
    }
  }
}