chromium/ui/file_manager/file_manager/foreground/js/ui/splitter.ts

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

/**
 * @fileoverview This implements a splitter element which can be used to resize
 * elements in split panes.
 *
 * The parent of the splitter should be an hbox (display: -webkit-box) with at
 * least one previous element sibling. The splitter controls the width of the
 * element before it.
 *
 * <div class=split-pane>
 *   <div class=left>...</div>
 *   <div class=splitter></div>
 *   ...
 * </div>
 *
 */

import {dispatchSimpleEvent} from 'chrome://resources/ash/common/cr_deprecated.js';

interface SplitterHandlers {
  touchmove?: (event: TouchEvent) => void;
  touchstart?: (event: TouchEvent) => void;
  touchend?: (event: TouchEvent) => void;
  touchcancel?: (event: TouchEvent) => void;

  mousemove?: (event: MouseEvent) => void;
  mouseup?: (event: MouseEvent) => void;
}

/**
 * Creates a new splitter element.
 */
export class Splitter extends HTMLDivElement {
  private resizeNextElement_: boolean = false;
  private handlers_: SplitterHandlers|null = null;
  private startX_: number = 0;
  private startWidth_: number = 0;

  /**
   * Initializes the element.
   */
  initialize(..._args: any[]) {
    this.addEventListener('mousedown', this.handleMouseDown_.bind(this), true);
    this.addEventListener(
        'touchstart', this.handleTouchStart_.bind(this), true);
    this.resizeNextElement_ = false;
  }

  /**
   * @param resizeNext True if resize the next element. By default, splitter
   *     resizes previous (left) element.
   */
  set resizeNextElement(resizeNext: boolean) {
    this.resizeNextElement_ = resizeNext;
  }

  /**
   * Starts the dragging of the splitter. Adds listeners for mouse or touch
   * events and calls splitter drag start handler.
   * @param clientX X position of the mouse or touch event that
   *                         started the drag.
   * @param isTouchEvent True if the drag started by touch event.
   */
  startDrag(clientX: number, isTouchEvent: boolean) {
    if (this.handlers_) {
      // Case of concurrent drags.
      this.endDrag_();
    }
    if (isTouchEvent) {
      const endDragBound = this.endDrag_.bind(this);
      this.handlers_ = {
        'touchmove': this.handleTouchMove_.bind(this),
        'touchend': endDragBound,
        'touchcancel': endDragBound,

        // Another touch start (we somehow missed touchend or touchcancel).
        'touchstart': endDragBound,
      };
    } else {
      this.handlers_ = {
        'mousemove': this.handleMouseMove_.bind(this),
        'mouseup': this.handleMouseUp_.bind(this),
      };
    }

    const doc = this.ownerDocument;

    // Use capturing events on the document to get events when the mouse
    // leaves the document.
    for (const [eventType, handler] of Object.entries(this.handlers_)) {
      doc.addEventListener(eventType, handler, true);
    }

    this.startX_ = clientX;
    this.handleSplitterDragStart();
  }

  /**
   * Ends the dragging of the splitter. Removes listeners set in startDrag
   * and calls splitter drag end handler.
   */
  private endDrag_() {
    const doc = this.ownerDocument;
    if (this.handlers_) {
      for (const [eventType, handler] of Object.entries(this.handlers_)) {
        doc.removeEventListener(eventType, handler, true);
      }
    }
    this.handlers_ = null;
    this.handleSplitterDragEnd();
  }

  private getResizeTarget_(): HTMLElement {
    return (this.resizeNextElement_ ? this.nextElementSibling! :
                                      this.previousElementSibling!) as
        HTMLElement;
  }

  /**
   * Calculate width to resize target element.
   * @param deltaX horizontal drag amount
   */
  private calcDeltaX_(deltaX: number): number {
    return this.resizeNextElement_ ? -deltaX : deltaX;
  }

  /**
   * Handles the mousedown event which starts the dragging of the splitter.
   */
  private handleMouseDown_(e: MouseEvent) {
    if (e.button) {
      return;
    }
    this.startDrag(e.clientX, false);
    // Default action is to start selection and to move focus.
    e.preventDefault();
  }

  /**
   * Handles the touchstart event which starts the dragging of the splitter.
   */
  private handleTouchStart_(e: TouchEvent) {
    if (e.touches.length === 1) {
      this.startDrag(e.touches[0]!.clientX, true);
      if (e.cancelable) {
        e.preventDefault();
      }
    }
  }

  /**
   * Handles the mousemove event which moves the splitter as the user moves
   * the mouse.
   */
  private handleMouseMove_(e: MouseEvent) {
    this.handleMove_(e.clientX);
  }

  /**
   * Handles the touch move event.
   * @param e The touch event.
   */
  private handleTouchMove_(e: TouchEvent) {
    if (e.touches.length === 1) {
      this.handleMove_(e.touches[0]!.clientX);
    }
  }

  /**
   * Common part of handling mousemove and touchmove. Calls splitter drag
   * move handler.
   * @param clientX X position of the mouse or touch event.
   */
  private handleMove_(clientX: number) {
    const rtl =
        this.ownerDocument.defaultView!.getComputedStyle(this).direction ===
        'rtl';
    const dirMultiplier = rtl ? -1 : 1;
    const deltaX = dirMultiplier * (clientX - this.startX_);
    this.handleSplitterDragMove(deltaX);
  }

  /**
   * Handles the mouse up event which ends the dragging of the splitter.
   */
  private handleMouseUp_(_e: MouseEvent) {
    this.endDrag_();
  }

  /**
   * Handles start of the splitter dragging. Saves current width of the
   * element being resized.
   */
  handleSplitterDragStart() {
    // Use the computed width style as the base so that we can ignore what
    // box sizing the element has. Add the difference between offset and
    // client widths to account for any scrollbars.
    const targetElement = this.getResizeTarget_();
    const doc = targetElement.ownerDocument;
    this.startWidth_ =
        parseFloat(doc.defaultView!.getComputedStyle(targetElement).width) +
        targetElement.offsetWidth - targetElement.clientWidth;

    this.classList.add('splitter-active');
  }

  /**
   * Handles splitter moves. Updates width of the element being resized.
   * @param deltaX The change of splitter horizontal position.
   */
  handleSplitterDragMove(deltaX: number) {
    const targetElement = this.getResizeTarget_();
    const newWidth = this.startWidth_ + this.calcDeltaX_(deltaX);
    targetElement.style.width = newWidth + 'px';
    dispatchSimpleEvent(this, 'dragmove');
  }

  /**
   * Handles end of the splitter dragging. This fires a 'resize' event if
   * the size changed.
   */
  handleSplitterDragEnd() {
    // Check if the size changed.
    const targetElement = this.getResizeTarget_();
    const doc = targetElement.ownerDocument;
    const computedWidth =
        parseFloat(doc.defaultView!.getComputedStyle(targetElement).width);
    if (this.startWidth_ !== computedWidth) {
      dispatchSimpleEvent(this, 'resize');
    }

    this.classList.remove('splitter-active');
  }
}