/**
 * @license
 * Copyright 2011 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Object representing a scrollbar.
 */
'use strict';

/**
 * Object representing a scrollbar.
 * @class
 */
goog.module('Blockly.Scrollbar');

const Touch = goog.require('Blockly.Touch');
const browserEvents = goog.require('Blockly.browserEvents');
const dom = goog.require('Blockly.utils.dom');
const svgMath = goog.require('Blockly.utils.svgMath');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
/* eslint-disable-next-line no-unused-vars */
const {Metrics} = goog.requireType('Blockly.utils.Metrics');
const {Svg} = goog.require('Blockly.utils.Svg');
/* eslint-disable-next-line no-unused-vars */
const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');


/**
 * A note on units: most of the numbers that are in CSS pixels are scaled if the
 * scrollbar is in a mutator.
 */

/**
 * Class for a pure SVG scrollbar.
 * This technique offers a scrollbar that is guaranteed to work, but may not
 * look or behave like the system's scrollbars.
 * @param {!WorkspaceSvg} workspace Workspace to bind the scrollbar to.
 * @param {boolean} horizontal True if horizontal, false if vertical.
 * @param {boolean=} opt_pair True if scrollbar is part of a horiz/vert pair.
 * @param {string=} opt_class A class to be applied to this scrollbar.
 * @param {number=} opt_margin The margin to apply to this scrollbar.
 * @constructor
 * @alias Blockly.Scrollbar
 */
const Scrollbar = function(
    workspace, horizontal, opt_pair, opt_class, opt_margin) {
  /**
   * The workspace this scrollbar is bound to.
   * @type {!WorkspaceSvg}
   * @private
   */
  this.workspace_ = workspace;
  /**
   * Whether this scrollbar is part of a pair.
   * @type {boolean}
   * @private
   */
  this.pair_ = opt_pair || false;
  /**
   * Whether this is a horizontal scrollbar.
   * @type {boolean}
   * @private
   */
  this.horizontal_ = horizontal;
  /**
   * Margin around the scrollbar (between the scrollbar and the edge of the
   * viewport in pixels).
   * @type {number}
   * @const
   * @private
   */
  this.margin_ = (opt_margin !== undefined) ?
      opt_margin :
      Scrollbar.DEFAULT_SCROLLBAR_MARGIN;
  /**
   * Previously recorded metrics from the workspace.
   * @type {?Metrics}
   * @private
   */
  this.oldHostMetrics_ = null;
  /**
   * The ratio of handle position offset to workspace content displacement.
   * @type {?number}
   * @package
   */
  this.ratio = null;

  this.createDom_(opt_class);

  /**
   * The upper left corner of the scrollbar's SVG group in CSS pixels relative
   * to the scrollbar's origin.  This is usually relative to the injection div
   * origin.
   * @type {Coordinate}
   * @package
   */
  this.position = new Coordinate(0, 0);

  // Store the thickness in a temp variable for readability.
  const scrollbarThickness = Scrollbar.scrollbarThickness;
  if (horizontal) {
    this.svgBackground_.setAttribute('height', scrollbarThickness);
    this.outerSvg_.setAttribute('height', scrollbarThickness);
    this.svgHandle_.setAttribute('height', scrollbarThickness - 5);
    this.svgHandle_.setAttribute('y', 2.5);

    this.lengthAttribute_ = 'width';
    this.positionAttribute_ = 'x';
  } else {
    this.svgBackground_.setAttribute('width', scrollbarThickness);
    this.outerSvg_.setAttribute('width', scrollbarThickness);
    this.svgHandle_.setAttribute('width', scrollbarThickness - 5);
    this.svgHandle_.setAttribute('x', 2.5);

    this.lengthAttribute_ = 'height';
    this.positionAttribute_ = 'y';
  }
  const scrollbar = this;
  this.onMouseDownBarWrapper_ = browserEvents.conditionalBind(
      this.svgBackground_, 'mousedown', scrollbar, scrollbar.onMouseDownBar_);
  this.onMouseDownHandleWrapper_ = browserEvents.conditionalBind(
      this.svgHandle_, 'mousedown', scrollbar, scrollbar.onMouseDownHandle_);
};

/**
 * The location of the origin of the workspace that the scrollbar is in,
 * measured in CSS pixels relative to the injection div origin.  This is usually
 * (0, 0).  When the scrollbar is in a flyout it may have a different origin.
 * @type {Coordinate}
 * @private
 */
Scrollbar.prototype.origin_ = new Coordinate(0, 0);

/**
 * The position of the mouse along this scrollbar's major axis at the start of
 * the most recent drag.
 * Units are CSS pixels, with (0, 0) at the top left of the browser window.
 * For a horizontal scrollbar this is the x coordinate of the mouse down event;
 * for a vertical scrollbar it's the y coordinate of the mouse down event.
 * @type {number}
 * @private
 */
Scrollbar.prototype.startDragMouse_ = 0;

/**
 * The length of the scrollbars (including the handle and the background), in
 * CSS pixels. This is equivalent to scrollbar background length and the area
 * within which the scrollbar handle can move.
 * @type {number}
 * @private
 */
Scrollbar.prototype.scrollbarLength_ = 0;

/**
 * The length of the scrollbar handle in CSS pixels.
 * @type {number}
 * @private
 */
Scrollbar.prototype.handleLength_ = 0;

/**
 * The offset of the start of the handle from the scrollbar position, in CSS
 * pixels.
 * @type {number}
 * @private
 */
Scrollbar.prototype.handlePosition_ = 0;

/**
 * Whether the scrollbar handle is visible.
 * @type {boolean}
 * @private
 */
Scrollbar.prototype.isVisible_ = true;

/**
 * Whether the workspace containing this scrollbar is visible.
 * @type {boolean}
 * @private
 */
Scrollbar.prototype.containerVisible_ = true;

/**
 * Width of vertical scrollbar or height of horizontal scrollbar in CSS pixels.
 * Scrollbars should be larger on touch devices.
 */
Scrollbar.scrollbarThickness = 15;
if (Touch.TOUCH_ENABLED) {
  Scrollbar.scrollbarThickness = 25;
}

/**
 * Default margin around the scrollbar (between the scrollbar and the edge of
 * the viewport in pixels).
 * @type {number}
 * @const
 * @package
 */
Scrollbar.DEFAULT_SCROLLBAR_MARGIN = 0.5;


/**
 * @param {!Metrics} first An object containing computed
 *     measurements of a workspace.
 * @param {!Metrics} second Another object containing computed
 *     measurements of a workspace.
 * @return {boolean} Whether the two sets of metrics are equivalent.
 * @private
 */
Scrollbar.metricsAreEquivalent_ = function(first, second) {
  return (
      first.viewWidth === second.viewWidth &&
      first.viewHeight === second.viewHeight &&
      first.viewLeft === second.viewLeft && first.viewTop === second.viewTop &&
      first.absoluteTop === second.absoluteTop &&
      first.absoluteLeft === second.absoluteLeft &&
      first.scrollWidth === second.scrollWidth &&
      first.scrollHeight === second.scrollHeight &&
      first.scrollLeft === second.scrollLeft &&
      first.scrollTop === second.scrollTop);
};

/**
 * Dispose of this scrollbar.
 * Unlink from all DOM elements to prevent memory leaks.
 * @suppress {checkTypes}
 */
Scrollbar.prototype.dispose = function() {
  this.cleanUp_();
  browserEvents.unbind(this.onMouseDownBarWrapper_);
  this.onMouseDownBarWrapper_ = null;
  browserEvents.unbind(this.onMouseDownHandleWrapper_);
  this.onMouseDownHandleWrapper_ = null;

  dom.removeNode(this.outerSvg_);
  this.outerSvg_ = null;
  this.svgGroup_ = null;
  this.svgBackground_ = null;
  if (this.svgHandle_) {
    this.workspace_.getThemeManager().unsubscribe(this.svgHandle_);
    this.svgHandle_ = null;
  }
  this.workspace_ = null;
};

/**
 * Constrain the handle's length within the minimum (0) and maximum
 * (scrollbar background) values allowed for the scrollbar.
 * @param {number} value Value that is potentially out of bounds, in CSS pixels.
 * @return {number} Constrained value, in CSS pixels.
 * @private
 */
Scrollbar.prototype.constrainHandleLength_ = function(value) {
  if (value <= 0 || isNaN(value)) {
    value = 0;
  } else {
    value = Math.min(value, this.scrollbarLength_);
  }
  return value;
};

/**
 * Set the length of the scrollbar's handle and change the SVG attribute
 * accordingly.
 * @param {number} newLength The new scrollbar handle length in CSS pixels.
 * @private
 */
Scrollbar.prototype.setHandleLength_ = function(newLength) {
  this.handleLength_ = newLength;
  this.svgHandle_.setAttribute(this.lengthAttribute_, this.handleLength_);
};

/**
 * Constrain the handle's position within the minimum (0) and maximum values
 * allowed for the scrollbar.
 * @param {number} value Value that is potentially out of bounds, in CSS pixels.
 * @return {number} Constrained value, in CSS pixels.
 * @private
 */
Scrollbar.prototype.constrainHandlePosition_ = function(value) {
  if (value <= 0 || isNaN(value)) {
    value = 0;
  } else {
    // Handle length should never be greater than this.scrollbarLength_.
    // If the viewSize is greater than or equal to the scrollSize, the
    // handleLength will end up equal to this.scrollbarLength_.
    value = Math.min(value, this.scrollbarLength_ - this.handleLength_);
  }
  return value;
};

/**
 * Set the offset of the scrollbar's handle from the scrollbar's position, and
 * change the SVG attribute accordingly.
 * @param {number} newPosition The new scrollbar handle offset in CSS pixels.
 */
Scrollbar.prototype.setHandlePosition = function(newPosition) {
  this.handlePosition_ = newPosition;
  this.svgHandle_.setAttribute(this.positionAttribute_, this.handlePosition_);
};

/**
 * Set the size of the scrollbar's background and change the SVG attribute
 * accordingly.
 * @param {number} newSize The new scrollbar background length in CSS pixels.
 * @private
 */
Scrollbar.prototype.setScrollbarLength_ = function(newSize) {
  this.scrollbarLength_ = newSize;
  this.outerSvg_.setAttribute(this.lengthAttribute_, this.scrollbarLength_);
  this.svgBackground_.setAttribute(
      this.lengthAttribute_, this.scrollbarLength_);
};

/**
 * Set the position of the scrollbar's SVG group in CSS pixels relative to the
 * scrollbar's origin.  This sets the scrollbar's location within the workspace.
 * @param {number} x The new x coordinate.
 * @param {number} y The new y coordinate.
 * @package
 */
Scrollbar.prototype.setPosition = function(x, y) {
  this.position.x = x;
  this.position.y = y;

  const tempX = this.position.x + this.origin_.x;
  const tempY = this.position.y + this.origin_.y;
  const transform = 'translate(' + tempX + 'px,' + tempY + 'px)';
  dom.setCssTransform(this.outerSvg_, transform);
};

/**
 * Recalculate the scrollbar's location and its length.
 * @param {Metrics=} opt_metrics A data structure of from the
 *     describing all the required dimensions.  If not provided, it will be
 *     fetched from the host object.
 */
Scrollbar.prototype.resize = function(opt_metrics) {
  // Determine the location, height and width of the host element.
  let hostMetrics = opt_metrics;
  if (!hostMetrics) {
    hostMetrics = this.workspace_.getMetrics();
    if (!hostMetrics) {
      // Host element is likely not visible.
      return;
    }
  }

  if (this.oldHostMetrics_ &&
      Scrollbar.metricsAreEquivalent_(hostMetrics, this.oldHostMetrics_)) {
    return;
  }

  if (this.horizontal_) {
    this.resizeHorizontal_(hostMetrics);
  } else {
    this.resizeVertical_(hostMetrics);
  }

  this.oldHostMetrics_ = hostMetrics;

  // Resizing may have caused some scrolling.
  this.updateMetrics_();
};

/**
 * Returns whether the a resizeView is necessary by comparing the passed
 * hostMetrics with cached old host metrics.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 * @return {boolean} Whether a resizeView is necessary.
 * @private
 */
Scrollbar.prototype.requiresViewResize_ = function(hostMetrics) {
  if (!this.oldHostMetrics_) {
    return true;
  }
  return this.oldHostMetrics_.viewWidth !== hostMetrics.viewWidth ||
      this.oldHostMetrics_.viewHeight !== hostMetrics.viewHeight ||
      this.oldHostMetrics_.absoluteLeft !== hostMetrics.absoluteLeft ||
      this.oldHostMetrics_.absoluteTop !== hostMetrics.absoluteTop;
};

/**
 * Recalculate a horizontal scrollbar's location and length.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 * @private
 */
Scrollbar.prototype.resizeHorizontal_ = function(hostMetrics) {
  if (this.requiresViewResize_(hostMetrics)) {
    this.resizeViewHorizontal(hostMetrics);
  } else {
    this.resizeContentHorizontal(hostMetrics);
  }
};

/**
 * Recalculate a horizontal scrollbar's location on the screen and path length.
 * This should be called when the layout or size of the window has changed.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 */
Scrollbar.prototype.resizeViewHorizontal = function(hostMetrics) {
  let viewSize = hostMetrics.viewWidth - this.margin_ * 2;
  if (this.pair_) {
    // Shorten the scrollbar to make room for the corner square.
    viewSize -= Scrollbar.scrollbarThickness;
  }
  this.setScrollbarLength_(Math.max(0, viewSize));

  let xCoordinate = hostMetrics.absoluteLeft + this.margin_;
  if (this.pair_ && this.workspace_.RTL) {
    xCoordinate += Scrollbar.scrollbarThickness;
  }

  // Horizontal toolbar should always be just above the bottom of the workspace.
  const yCoordinate = hostMetrics.absoluteTop + hostMetrics.viewHeight -
      Scrollbar.scrollbarThickness - this.margin_;
  this.setPosition(xCoordinate, yCoordinate);

  // If the view has been resized, a content resize will also be necessary.
  // The reverse is not true.
  this.resizeContentHorizontal(hostMetrics);
};

/**
 * Recalculate a horizontal scrollbar's location within its path and length.
 * This should be called when the contents of the workspace have changed.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 */
Scrollbar.prototype.resizeContentHorizontal = function(hostMetrics) {
  if (hostMetrics.viewWidth >= hostMetrics.scrollWidth) {
    // viewWidth is often greater than scrollWidth in flyouts and
    // non-scrollable workspaces.
    this.setHandleLength_(this.scrollbarLength_);
    this.setHandlePosition(0);
    if (!this.pair_) {
      // The scrollbar isn't needed.
      // This doesn't apply to scrollbar pairs because interactions with the
      // corner square aren't handled.
      this.setVisible(false);
    }
    return;
  } else if (!this.pair_) {
    // The scrollbar is needed. Only non-paired scrollbars are hidden/shown.
    this.setVisible(true);
  }

  // Resize the handle.
  let handleLength =
      this.scrollbarLength_ * hostMetrics.viewWidth / hostMetrics.scrollWidth;
  handleLength = this.constrainHandleLength_(handleLength);
  this.setHandleLength_(handleLength);

  // Compute the handle offset.
  // The position of the handle can be between:
  //     0 and this.scrollbarLength_ - handleLength
  // If viewLeft === scrollLeft
  //     then the offset should be 0
  // If viewRight === scrollRight
  //     then viewLeft = scrollLeft + scrollWidth - viewWidth
  //     then the offset should be max offset

  const maxScrollDistance = hostMetrics.scrollWidth - hostMetrics.viewWidth;
  const contentDisplacement = hostMetrics.viewLeft - hostMetrics.scrollLeft;
  // Percent of content to the left of our current position.
  const offsetRatio = contentDisplacement / maxScrollDistance;
  // Area available to scroll * percent to the left
  const maxHandleOffset = this.scrollbarLength_ - this.handleLength_;
  let handleOffset = maxHandleOffset * offsetRatio;
  handleOffset = this.constrainHandlePosition_(handleOffset);
  this.setHandlePosition(handleOffset);

  // Compute ratio (for use with set calls, which pass in content displacement).
  this.ratio = maxHandleOffset / maxScrollDistance;
};

/**
 * Recalculate a vertical scrollbar's location and length.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 * @private
 */
Scrollbar.prototype.resizeVertical_ = function(hostMetrics) {
  if (this.requiresViewResize_(hostMetrics)) {
    this.resizeViewVertical(hostMetrics);
  } else {
    this.resizeContentVertical(hostMetrics);
  }
};

/**
 * Recalculate a vertical scrollbar's location on the screen and path length.
 * This should be called when the layout or size of the window has changed.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 */
Scrollbar.prototype.resizeViewVertical = function(hostMetrics) {
  let viewSize = hostMetrics.viewHeight - this.margin_ * 2;
  if (this.pair_) {
    // Shorten the scrollbar to make room for the corner square.
    viewSize -= Scrollbar.scrollbarThickness;
  }
  this.setScrollbarLength_(Math.max(0, viewSize));

  const xCoordinate = this.workspace_.RTL ?
      hostMetrics.absoluteLeft + this.margin_ :
      hostMetrics.absoluteLeft + hostMetrics.viewWidth -
          Scrollbar.scrollbarThickness - this.margin_;

  const yCoordinate = hostMetrics.absoluteTop + this.margin_;
  this.setPosition(xCoordinate, yCoordinate);

  // If the view has been resized, a content resize will also be necessary.  The
  // reverse is not true.
  this.resizeContentVertical(hostMetrics);
};

/**
 * Recalculate a vertical scrollbar's location within its path and length.
 * This should be called when the contents of the workspace have changed.
 * @param {!Metrics} hostMetrics A data structure describing all
 *     the required dimensions, possibly fetched from the host object.
 */
Scrollbar.prototype.resizeContentVertical = function(hostMetrics) {
  if (hostMetrics.viewHeight >= hostMetrics.scrollHeight) {
    // viewHeight is often greater than scrollHeight in flyouts and
    // non-scrollable workspaces.
    this.setHandleLength_(this.scrollbarLength_);
    this.setHandlePosition(0);
    if (!this.pair_) {
      // The scrollbar isn't needed.
      // This doesn't apply to scrollbar pairs because interactions with the
      // corner square aren't handled.
      this.setVisible(false);
    }
    return;
  } else if (!this.pair_) {
    // The scrollbar is needed. Only non-paired scrollbars are hidden/shown.
    this.setVisible(true);
  }

  // Resize the handle.
  let handleLength =
      this.scrollbarLength_ * hostMetrics.viewHeight / hostMetrics.scrollHeight;
  handleLength = this.constrainHandleLength_(handleLength);
  this.setHandleLength_(handleLength);

  // Compute the handle offset.
  // The position of the handle can be between:
  //     0 and this.scrollbarLength_ - handleLength
  // If viewTop === scrollTop
  //     then the offset should be 0
  // If viewBottom === scrollBottom
  //     then viewTop = scrollTop + scrollHeight - viewHeight
  //     then the offset should be max offset

  const maxScrollDistance = hostMetrics.scrollHeight - hostMetrics.viewHeight;
  const contentDisplacement = hostMetrics.viewTop - hostMetrics.scrollTop;
  // Percent of content to the left of our current position.
  const offsetRatio = contentDisplacement / maxScrollDistance;
  // Area available to scroll * percent to the left
  const maxHandleOffset = this.scrollbarLength_ - this.handleLength_;
  let handleOffset = maxHandleOffset * offsetRatio;
  handleOffset = this.constrainHandlePosition_(handleOffset);
  this.setHandlePosition(handleOffset);

  // Compute ratio (for use with set calls, which pass in content displacement).
  this.ratio = maxHandleOffset / maxScrollDistance;
};

/**
 * Create all the DOM elements required for a scrollbar.
 * The resulting widget is not sized.
 * @param {string=} opt_class A class to be applied to this scrollbar.
 * @private
 */
Scrollbar.prototype.createDom_ = function(opt_class) {
  /* Create the following DOM:
  <svg class="blocklyScrollbarHorizontal  optionalClass">
    <g>
      <rect class="blocklyScrollbarBackground" />
      <rect class="blocklyScrollbarHandle" rx="8" ry="8" />
    </g>
  </svg>
  */
  let className =
      'blocklyScrollbar' + (this.horizontal_ ? 'Horizontal' : 'Vertical');
  if (opt_class) {
    className += ' ' + opt_class;
  }
  this.outerSvg_ = dom.createSvgElement(Svg.SVG, {'class': className}, null);
  this.svgGroup_ = dom.createSvgElement(Svg.G, {}, this.outerSvg_);
  this.svgBackground_ = dom.createSvgElement(
      Svg.RECT, {'class': 'blocklyScrollbarBackground'}, this.svgGroup_);
  const radius = Math.floor((Scrollbar.scrollbarThickness - 5) / 2);
  this.svgHandle_ = dom.createSvgElement(
      Svg.RECT, {'class': 'blocklyScrollbarHandle', 'rx': radius, 'ry': radius},
      this.svgGroup_);
  this.workspace_.getThemeManager().subscribe(
      this.svgHandle_, 'scrollbarColour', 'fill');
  this.workspace_.getThemeManager().subscribe(
      this.svgHandle_, 'scrollbarOpacity', 'fill-opacity');
  dom.insertAfter(this.outerSvg_, this.workspace_.getParentSvg());
};

/**
 * Is the scrollbar visible.  Non-paired scrollbars disappear when they aren't
 * needed.
 * @return {boolean} True if visible.
 */
Scrollbar.prototype.isVisible = function() {
  return this.isVisible_;
};

/**
 * Set whether the scrollbar's container is visible and update
 * display accordingly if visibility has changed.
 * @param {boolean} visible Whether the container is visible
 */
Scrollbar.prototype.setContainerVisible = function(visible) {
  const visibilityChanged = (visible !== this.containerVisible_);

  this.containerVisible_ = visible;
  if (visibilityChanged) {
    this.updateDisplay_();
  }
};

/**
 * Set whether the scrollbar is visible.
 * Only applies to non-paired scrollbars.
 * @param {boolean} visible True if visible.
 */
Scrollbar.prototype.setVisible = function(visible) {
  const visibilityChanged = (visible !== this.isVisible());

  // Ideally this would also apply to scrollbar pairs, but that's a bigger
  // headache (due to interactions with the corner square).
  if (this.pair_) {
    throw Error('Unable to toggle visibility of paired scrollbars.');
  }
  this.isVisible_ = visible;
  if (visibilityChanged) {
    this.updateDisplay_();
  }
};

/**
 * Update visibility of scrollbar based on whether it thinks it should
 * be visible and whether its containing workspace is visible.
 * We cannot rely on the containing workspace being hidden to hide us
 * because it is not necessarily our parent in the DOM.
 */
Scrollbar.prototype.updateDisplay_ = function() {
  let show = true;
  // Check whether our parent/container is visible.
  if (!this.containerVisible_) {
    show = false;
  } else {
    show = this.isVisible();
  }
  if (show) {
    this.outerSvg_.setAttribute('display', 'block');
  } else {
    this.outerSvg_.setAttribute('display', 'none');
  }
};

/**
 * Scroll by one pageful.
 * Called when scrollbar background is clicked.
 * @param {!Event} e Mouse down event.
 * @private
 */
Scrollbar.prototype.onMouseDownBar_ = function(e) {
  this.workspace_.markFocused();
  Touch.clearTouchIdentifier();  // This is really a click.
  this.cleanUp_();
  if (browserEvents.isRightButton(e)) {
    // Right-click.
    // Scrollbars have no context menu.
    e.stopPropagation();
    return;
  }
  const mouseXY = browserEvents.mouseToSvg(
      e, this.workspace_.getParentSvg(), this.workspace_.getInverseScreenCTM());
  const mouseLocation = this.horizontal_ ? mouseXY.x : mouseXY.y;

  const handleXY = svgMath.getInjectionDivXY(this.svgHandle_);
  const handleStart = this.horizontal_ ? handleXY.x : handleXY.y;
  let handlePosition = this.handlePosition_;

  const pageLength = this.handleLength_ * 0.95;
  if (mouseLocation <= handleStart) {
    // Decrease the scrollbar's value by a page.
    handlePosition -= pageLength;
  } else if (mouseLocation >= handleStart + this.handleLength_) {
    // Increase the scrollbar's value by a page.
    handlePosition += pageLength;
  }

  this.setHandlePosition(this.constrainHandlePosition_(handlePosition));

  this.updateMetrics_();
  e.stopPropagation();
  e.preventDefault();
};

/**
 * Start a dragging operation.
 * Called when scrollbar handle is clicked.
 * @param {!Event} e Mouse down event.
 * @private
 */
Scrollbar.prototype.onMouseDownHandle_ = function(e) {
  this.workspace_.markFocused();
  this.cleanUp_();
  if (browserEvents.isRightButton(e)) {
    // Right-click.
    // Scrollbars have no context menu.
    e.stopPropagation();
    return;
  }
  // Look up the current translation and record it.
  this.startDragHandle = this.handlePosition_;

  // Tell the workspace to setup its drag surface since it is about to move.
  // onMouseMoveHandle will call onScroll which actually tells the workspace
  // to move.
  this.workspace_.setupDragSurface();

  // Record the current mouse position.
  this.startDragMouse_ = this.horizontal_ ? e.clientX : e.clientY;
  Scrollbar.onMouseUpWrapper_ = browserEvents.conditionalBind(
      document, 'mouseup', this, this.onMouseUpHandle_);
  Scrollbar.onMouseMoveWrapper_ = browserEvents.conditionalBind(
      document, 'mousemove', this, this.onMouseMoveHandle_);
  e.stopPropagation();
  e.preventDefault();
};

/**
 * Drag the scrollbar's handle.
 * @param {!Event} e Mouse up event.
 * @private
 */
Scrollbar.prototype.onMouseMoveHandle_ = function(e) {
  const currentMouse = this.horizontal_ ? e.clientX : e.clientY;
  const mouseDelta = currentMouse - this.startDragMouse_;
  const handlePosition = this.startDragHandle + mouseDelta;
  // Position the bar.
  this.setHandlePosition(this.constrainHandlePosition_(handlePosition));
  this.updateMetrics_();
};

/**
 * Release the scrollbar handle and reset state accordingly.
 * @private
 */
Scrollbar.prototype.onMouseUpHandle_ = function() {
  // Tell the workspace to clean up now that the workspace is done moving.
  this.workspace_.resetDragSurface();
  Touch.clearTouchIdentifier();
  this.cleanUp_();
};

/**
 * Hide chaff and stop binding to mouseup and mousemove events.  Call this to
 * wrap up loose ends associated with the scrollbar.
 * @private
 */
Scrollbar.prototype.cleanUp_ = function() {
  this.workspace_.hideChaff(true);
  if (Scrollbar.onMouseUpWrapper_) {
    browserEvents.unbind(Scrollbar.onMouseUpWrapper_);
    Scrollbar.onMouseUpWrapper_ = null;
  }
  if (Scrollbar.onMouseMoveWrapper_) {
    browserEvents.unbind(Scrollbar.onMouseMoveWrapper_);
    Scrollbar.onMouseMoveWrapper_ = null;
  }
};

/**
 * Helper to calculate the ratio of handle position to scrollbar view size.
 * @return {number} Ratio.
 * @package
 */
Scrollbar.prototype.getRatio_ = function() {
  const scrollHandleRange = this.scrollbarLength_ - this.handleLength_;
  let ratio = this.handlePosition_ / scrollHandleRange;
  if (isNaN(ratio)) {
    ratio = 0;
  }
  return ratio;
};

/**
 * Updates workspace metrics based on new scroll ratio. Called when scrollbar is
 * moved.
 * @private
 */
Scrollbar.prototype.updateMetrics_ = function() {
  const ratio = this.getRatio_();
  const xyRatio = {};
  if (this.horizontal_) {
    xyRatio.x = ratio;
  } else {
    xyRatio.y = ratio;
  }
  this.workspace_.setMetrics(xyRatio);
};

/**
 * Set the scrollbar handle's position.
 * @param {number} value The content displacement, relative to the view in
 *    pixels.
 * @param {boolean=} updateMetrics Whether to update metrics on this set call.
 *    Defaults to true.
 */
Scrollbar.prototype.set = function(value, updateMetrics) {
  this.setHandlePosition(this.constrainHandlePosition_(value * this.ratio));
  if (updateMetrics || updateMetrics === undefined) {
    this.updateMetrics_();
  }
};

/**
 * Record the origin of the workspace that the scrollbar is in, in pixels
 * relative to the injection div origin. This is for times when the scrollbar is
 * used in an object whose origin isn't the same as the main workspace
 * (e.g. in a flyout.)
 * @param {number} x The x coordinate of the scrollbar's origin, in CSS pixels.
 * @param {number} y The y coordinate of the scrollbar's origin, in CSS pixels.
 */
Scrollbar.prototype.setOrigin = function(x, y) {
  this.origin_ = new Coordinate(x, y);
};

exports.Scrollbar = Scrollbar;
