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

/**
 * The class representing an in-progress gesture, e.g. a drag,
 * tap, or pinch to zoom.
 *
 * @class
 */
// Former goog.module ID: Blockly.Gesture

// Unused import preserved for side-effects. Remove if unneeded.
import './events/events_click.js';

import * as blockAnimations from './block_animations.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import {config} from './config.js';
import * as dropDownDiv from './dropdowndiv.js';
import * as eventUtils from './events/utils.js';
import type {Field} from './field.js';
import type {IBubble} from './interfaces/i_bubble.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceDragger} from './workspace_dragger.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import type {IIcon} from './interfaces/i_icon.js';
import {IDragger} from './interfaces/i_dragger.js';
import * as registry from './registry.js';
import {IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {RenderedWorkspaceComment} from './comments.js';

/**
 * Note: In this file "start" refers to pointerdown
 * events.  "End" refers to pointerup events.
 */

/** A multiplier used to convert the gesture scale to a zoom in delta. */
const ZOOM_IN_MULTIPLIER = 5;

/** A multiplier used to convert the gesture scale to a zoom out delta. */
const ZOOM_OUT_MULTIPLIER = 6;

/**
 * Class for one gesture.
 */
export class Gesture {
  /**
   * The position of the pointer when the gesture started.  Units are CSS
   * pixels, with (0, 0) at the top left of the browser window (pointer event
   * clientX/Y).
   */
  private mouseDownXY = new Coordinate(0, 0);
  private currentDragDeltaXY: Coordinate;

  /**
   * The bubble that the gesture started on, or null if it did not start on a
   * bubble.
   */
  private startBubble: IBubble | null = null;

  /**
   * The field that the gesture started on, or null if it did not start on a
   * field.
   */
  private startField: Field | null = null;

  /**
   * The icon that the gesture started on, or null if it did not start on an
   * icon.
   */
  private startIcon: IIcon | null = null;

  /**
   * The block that the gesture started on, or null if it did not start on a
   * block.
   */
  private startBlock: BlockSvg | null = null;

  /**
   * The comment that the gesture started on, or null if it did not start on a
   * comment.
   */
  private startComment: RenderedWorkspaceComment | null = null;

  /**
   * The block that this gesture targets.  If the gesture started on a
   * shadow block, this is the first non-shadow parent of the block.  If the
   * gesture started in the flyout, this is the root block of the block group
   * that was clicked or dragged.
   */
  private targetBlock: BlockSvg | null = null;

  /**
   * The workspace that the gesture started on.  There may be multiple
   * workspaces on a page; this is more accurate than using
   * Blockly.common.getMainWorkspace().
   */
  protected startWorkspace_: WorkspaceSvg | null = null;

  /**
   * Whether the pointer has at any point moved out of the drag radius.
   * A gesture that exceeds the drag radius is a drag even if it ends exactly
   * at its start point.
   */
  private hasExceededDragRadius = false;

  /**
   * Array holding info needed to unbind events.
   * Used for disposing.
   * Ex: [[node, name, func], [node, name, func]].
   */
  private boundEvents: browserEvents.Data[] = [];

  private dragger: IDragger | null = null;

  /**
   * The object tracking a workspace or flyout workspace drag, or null if none
   * is in progress.
   */
  private workspaceDragger: WorkspaceDragger | null = null;

  /** Whether the gesture is dragging or not. */
  private dragging: boolean = false;

  /** The flyout a gesture started in, if any. */
  private flyout: IFlyout | null = null;

  /** Boolean for sanity-checking that some code is only called once. */
  private calledUpdateIsDragging = false;

  /** Boolean for sanity-checking that some code is only called once. */
  private gestureHasStarted = false;

  /** Boolean used internally to break a cycle in disposal. */
  protected isEnding_ = false;

  /** The event that most recently updated this gesture. */
  private mostRecentEvent: PointerEvent;

  /** Boolean for whether or not this gesture is a multi-touch gesture. */
  private isMultiTouch_ = false;

  /** A map of cached points used for tracking multi-touch gestures. */
  private cachedPoints = new Map<string, Coordinate | null>();

  /**
   * This is the ratio between the starting distance between the touch points
   * and the most recent distance between the touch points.
   * Scales between 0 and 1 mean the most recent zoom was a zoom out.
   * Scales above 1.0 mean the most recent zoom was a zoom in.
   */
  private previousScale = 0;

  /** The starting distance between two touch points. */
  private startDistance = 0;

  /** Boolean for whether or not the workspace supports pinch-zoom. */
  private isPinchZoomEnabled: boolean | null = null;

  /**
   * The owner of the dropdownDiv when this gesture first starts.
   * Needed because we'll close the dropdown before fields get to
   * act on their events, and some fields care about who owns
   * the dropdown.
   */
  currentDropdownOwner: Field | null = null;

  /**
   * @param e The event that kicked off this gesture.
   * @param creatorWorkspace The workspace that created this gesture and has a
   *     reference to it.
   */
  constructor(
    e: PointerEvent,
    private readonly creatorWorkspace: WorkspaceSvg,
  ) {
    this.mostRecentEvent = e;

    /**
     * How far the pointer has moved during this drag, in pixel units.
     * (0, 0) is at this.mouseDownXY_.
     */
    this.currentDragDeltaXY = new Coordinate(0, 0);
  }

  /**
   * Sever all links from this object.
   *
   * @internal
   */
  dispose() {
    Touch.clearTouchIdentifier();
    Tooltip.unblock();
    // Clear the owner's reference to this gesture.
    this.creatorWorkspace.clearGesture();

    for (const event of this.boundEvents) {
      browserEvents.unbind(event);
    }
    this.boundEvents.length = 0;

    if (this.workspaceDragger) {
      this.workspaceDragger.dispose();
    }
  }

  /**
   * Update internal state based on an event.
   *
   * @param e The most recent pointer event.
   */
  private updateFromEvent(e: PointerEvent) {
    const currentXY = new Coordinate(e.clientX, e.clientY);
    const changed = this.updateDragDelta(currentXY);
    // Exceeded the drag radius for the first time.
    if (changed) {
      this.updateIsDragging(e);
      Touch.longStop();
    }
    this.mostRecentEvent = e;
  }

  /**
   * DO MATH to set currentDragDeltaXY_ based on the most recent pointer
   * position.
   *
   * @param currentXY The most recent pointer position, in pixel units,
   *     with (0, 0) at the window's top left corner.
   * @returns True if the drag just exceeded the drag radius for the first time.
   */
  private updateDragDelta(currentXY: Coordinate): boolean {
    this.currentDragDeltaXY = Coordinate.difference(
      currentXY,
      this.mouseDownXY,
    );

    if (!this.hasExceededDragRadius) {
      const currentDragDelta = Coordinate.magnitude(this.currentDragDeltaXY);

      // The flyout has a different drag radius from the rest of Blockly.
      const limitRadius = this.flyout
        ? config.flyoutDragRadius
        : config.dragRadius;

      this.hasExceededDragRadius = currentDragDelta > limitRadius;
      return this.hasExceededDragRadius;
    }
    return false;
  }

  /**
   * Update this gesture to record whether a block is being dragged from the
   * flyout.
   * This function should be called on a pointermove event the first time
   * the drag radius is exceeded.  It should be called no more than once per
   * gesture. If a block should be dragged from the flyout this function creates
   * the new block on the main workspace and updates targetBlock_ and
   * startWorkspace_.
   *
   * @returns True if a block is being dragged from the flyout.
   */
  private updateIsDraggingFromFlyout(): boolean {
    if (!this.targetBlock || !this.flyout?.isBlockCreatable(this.targetBlock)) {
      return false;
    }
    if (!this.flyout.targetWorkspace) {
      throw new Error(`Cannot update dragging from the flyout because the ' +
          'flyout's target workspace is undefined`);
    }
    if (
      !this.flyout.isScrollable() ||
      this.flyout.isDragTowardWorkspace(this.currentDragDeltaXY)
    ) {
      this.startWorkspace_ = this.flyout.targetWorkspace;
      this.startWorkspace_.updateScreenCalculationsIfScrolled();
      // Start the event group now, so that the same event group is used for
      // block creation and block dragging.
      if (!eventUtils.getGroup()) {
        eventUtils.setGroup(true);
      }
      // The start block is no longer relevant, because this is a drag.
      this.startBlock = null;
      this.targetBlock = this.flyout.createBlock(this.targetBlock);
      common.setSelected(this.targetBlock);
      return true;
    }
    return false;
  }

  /**
   * Check whether to start a workspace drag. If a workspace is being dragged,
   * create the necessary WorkspaceDragger and start the drag.
   *
   * This function should be called on a pointermove event the first time
   * the drag radius is exceeded.  It should be called no more than once per
   * gesture. If a workspace is being dragged this function creates the
   * necessary WorkspaceDragger and starts the drag.
   */
  private updateIsDraggingWorkspace() {
    if (!this.startWorkspace_) {
      throw new Error(
        'Cannot update dragging the workspace because the ' +
          'start workspace is undefined',
      );
    }

    const wsMovable = this.flyout
      ? this.flyout.isScrollable()
      : this.startWorkspace_ && this.startWorkspace_.isDraggable();
    if (!wsMovable) return;

    this.dragging = true;
    this.workspaceDragger = new WorkspaceDragger(this.startWorkspace_);

    this.workspaceDragger.startDrag();
  }

  /**
   * Update this gesture to record whether anything is being dragged.
   * This function should be called on a pointermove event the first time
   * the drag radius is exceeded.  It should be called no more than once per
   * gesture.
   */
  private updateIsDragging(e: PointerEvent) {
    if (!this.startWorkspace_) {
      throw new Error(
        'Cannot update dragging because the start workspace is undefined',
      );
    }

    if (this.calledUpdateIsDragging) {
      throw Error('updateIsDragging_ should only be called once per gesture.');
    }
    this.calledUpdateIsDragging = true;

    // If we drag a block out of the flyout, it updates `common.getSelected`
    // to return the new block.
    if (this.flyout) this.updateIsDraggingFromFlyout();

    const selected = common.getSelected();
    if (selected && isDraggable(selected) && selected.isMovable()) {
      this.dragging = true;
      this.dragger = this.createDragger(selected, this.startWorkspace_);
      this.dragger.onDragStart(e);
      this.dragger.onDrag(e, this.currentDragDeltaXY);
    } else {
      this.updateIsDraggingWorkspace();
    }
  }

  private createDragger(
    draggable: IDraggable,
    workspace: WorkspaceSvg,
  ): IDragger {
    const DraggerClass = registry.getClassFromOptions(
      registry.Type.BLOCK_DRAGGER,
      this.creatorWorkspace.options,
      true,
    );
    return new DraggerClass!(draggable, workspace);
  }

  /**
   * Start a gesture: update the workspace to indicate that a gesture is in
   * progress and bind pointermove and pointerup handlers.
   *
   * @param e A pointerdown event.
   * @internal
   */
  doStart(e: PointerEvent) {
    if (!this.startWorkspace_) {
      throw new Error(
        'Cannot start the touch gesture becauase the start ' +
          'workspace is undefined',
      );
    }
    this.isPinchZoomEnabled =
      this.startWorkspace_.options.zoomOptions &&
      this.startWorkspace_.options.zoomOptions.pinch;

    if (browserEvents.isTargetInput(e)) {
      this.cancel();
      return;
    }

    this.gestureHasStarted = true;

    blockAnimations.disconnectUiStop();

    this.startWorkspace_.updateScreenCalculationsIfScrolled();
    if (this.startWorkspace_.isMutator) {
      // Mutator's coordinate system could be out of date because the bubble was
      // dragged, the block was moved, the parent workspace zoomed, etc.
      this.startWorkspace_.resize();
    }

    // Keep track of which field owns the dropdown before we close it.
    this.currentDropdownOwner = dropDownDiv.getOwner();
    // Hide chaff also hides the flyout, so don't do it if the click is in a
    // flyout.
    this.startWorkspace_.hideChaff(!!this.flyout);

    this.startWorkspace_.markFocused();
    this.mostRecentEvent = e;

    Tooltip.block();

    if (browserEvents.isRightButton(e)) {
      this.handleRightClick(e);
      return;
    }

    if (e.type.toLowerCase() === 'pointerdown' && e.pointerType !== 'mouse') {
      Touch.longStart(e, this);
    }

    this.mouseDownXY = new Coordinate(e.clientX, e.clientY);

    this.bindMouseEvents(e);

    if (!this.isEnding_) {
      this.handleTouchStart(e);
    }
  }

  /**
   * Bind gesture events.
   *
   * @param e A pointerdown event.
   * @internal
   */
  bindMouseEvents(e: PointerEvent) {
    this.boundEvents.push(
      browserEvents.conditionalBind(
        document,
        'pointerdown',
        null,
        this.handleStart.bind(this),
        /* opt_noCaptureIdentifier */ true,
      ),
    );
    this.boundEvents.push(
      browserEvents.conditionalBind(
        document,
        'pointermove',
        null,
        this.handleMove.bind(this),
        /* opt_noCaptureIdentifier */ true,
      ),
    );
    this.boundEvents.push(
      browserEvents.conditionalBind(
        document,
        'pointerup',
        null,
        this.handleUp.bind(this),
        /* opt_noCaptureIdentifier */ true,
      ),
    );

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

  /**
   * Handle a pointerdown event.
   *
   * @param e A pointerdown event.
   * @internal
   */
  handleStart(e: PointerEvent) {
    if (this.isDragging()) {
      // A drag has already started, so this can no longer be a pinch-zoom.
      return;
    }
    this.handleTouchStart(e);

    if (this.isMultiTouch()) {
      Touch.longStop();
    }
  }

  /**
   * Handle a pointermove event.
   *
   * @param e A pointermove event.
   * @internal
   */
  handleMove(e: PointerEvent) {
    if (
      (this.isDragging() && Touch.shouldHandleEvent(e)) ||
      !this.isMultiTouch()
    ) {
      this.updateFromEvent(e);
      if (this.workspaceDragger) {
        this.workspaceDragger.drag(this.currentDragDeltaXY);
      } else if (this.dragger) {
        this.dragger.onDrag(this.mostRecentEvent, this.currentDragDeltaXY);
      }
      e.preventDefault();
      e.stopPropagation();
    } else if (this.isMultiTouch()) {
      this.handleTouchMove(e);
      Touch.longStop();
    }
  }

  /**
   * Handle a pointerup event.
   *
   * @param e A pointerup event.
   * @internal
   */
  handleUp(e: PointerEvent) {
    if (!this.isDragging()) {
      this.handleTouchEnd(e);
    }
    if (!this.isMultiTouch() || this.isDragging()) {
      if (!Touch.shouldHandleEvent(e)) {
        return;
      }
      this.updateFromEvent(e);
      Touch.longStop();

      if (this.isEnding_) {
        console.log('Trying to end a gesture recursively.');
        return;
      }
      this.isEnding_ = true;
      // The ordering of these checks is important: drags have higher priority
      // than clicks.  Fields and icons have higher priority than blocks; blocks
      // have higher priority than workspaces. The ordering within drags does
      // not matter, because the three types of dragging are exclusive.
      if (this.dragger) {
        this.dragger.onDragEnd(e, this.currentDragDeltaXY);
      } else if (this.workspaceDragger) {
        this.workspaceDragger.endDrag(this.currentDragDeltaXY);
      } else if (this.isBubbleClick()) {
        // Do nothing, bubbles don't currently respond to clicks.
      } else if (this.isCommentClick()) {
        // Do nothing, comments don't currently respond to clicks.
      } else if (this.isFieldClick()) {
        this.doFieldClick();
      } else if (this.isIconClick()) {
        this.doIconClick();
      } else if (this.isBlockClick()) {
        this.doBlockClick();
      } else if (this.isWorkspaceClick()) {
        this.doWorkspaceClick(e);
      }

      e.preventDefault();
      e.stopPropagation();

      this.dispose();
    } else {
      e.preventDefault();
      e.stopPropagation();

      this.dispose();
    }
  }

  /**
   * Handle a pointerdown event and keep track of current
   * pointers.
   *
   * @param e A pointerdown event.
   * @internal
   */
  handleTouchStart(e: PointerEvent) {
    const pointerId = Touch.getTouchIdentifierFromEvent(e);
    // store the pointerId in the current list of pointers
    this.cachedPoints.set(pointerId, this.getTouchPoint(e));
    const pointers = Array.from(this.cachedPoints.keys());
    // If two pointers are down, store info
    if (pointers.length === 2) {
      const point0 = this.cachedPoints.get(pointers[0])!;
      const point1 = this.cachedPoints.get(pointers[1])!;
      this.startDistance = Coordinate.distance(point0, point1);
      this.isMultiTouch_ = true;
      e.preventDefault();
    }
  }

  /**
   * Handle a pointermove event and zoom in/out if two pointers
   * are on the screen.
   *
   * @param e A pointermove event.
   * @internal
   */
  handleTouchMove(e: PointerEvent) {
    const pointerId = Touch.getTouchIdentifierFromEvent(e);
    // Update the cache
    this.cachedPoints.set(pointerId, this.getTouchPoint(e));

    if (this.isPinchZoomEnabled && this.cachedPoints.size === 2) {
      this.handlePinch(e);
    } else {
      this.handleMove(e);
    }
  }

  /**
   * Handle pinch zoom gesture.
   *
   * @param e A pointermove event.
   */
  private handlePinch(e: PointerEvent) {
    const pointers = Array.from(this.cachedPoints.keys());
    // Calculate the distance between the two pointers
    const point0 = this.cachedPoints.get(pointers[0])!;
    const point1 = this.cachedPoints.get(pointers[1])!;
    const moveDistance = Coordinate.distance(point0, point1);
    const scale = moveDistance / this.startDistance;

    if (this.previousScale > 0 && this.previousScale < Infinity) {
      const gestureScale = scale - this.previousScale;
      const delta =
        gestureScale > 0
          ? gestureScale * ZOOM_IN_MULTIPLIER
          : gestureScale * ZOOM_OUT_MULTIPLIER;
      if (!this.startWorkspace_) {
        throw new Error(
          'Cannot handle a pinch because the start workspace ' + 'is undefined',
        );
      }
      const workspace = this.startWorkspace_;
      const position = browserEvents.mouseToSvg(
        e,
        workspace.getParentSvg(),
        workspace.getInverseScreenCTM(),
      );
      workspace.zoom(position.x, position.y, delta);
    }
    this.previousScale = scale;
    e.preventDefault();
  }

  /**
   * Handle a pointerup event and end the gesture.
   *
   * @param e A pointerup event.
   * @internal
   */
  handleTouchEnd(e: PointerEvent) {
    const pointerId = Touch.getTouchIdentifierFromEvent(e);
    if (this.cachedPoints.has(pointerId)) {
      this.cachedPoints.delete(pointerId);
    }
    if (this.cachedPoints.size < 2) {
      this.cachedPoints.clear();
      this.previousScale = 0;
    }
  }

  /**
   * Helper function returning the current touch point coordinate.
   *
   * @param e A pointer event.
   * @returns The current touch point coordinate
   * @internal
   */
  getTouchPoint(e: PointerEvent): Coordinate | null {
    if (!this.startWorkspace_) {
      return null;
    }
    return new Coordinate(e.pageX, e.pageY);
  }

  /**
   * Whether this gesture is part of a multi-touch gesture.
   *
   * @returns Whether this gesture is part of a multi-touch gesture.
   * @internal
   */
  isMultiTouch(): boolean {
    return this.isMultiTouch_;
  }

  /**
   * Cancel an in-progress gesture.  If a workspace or block drag is in
   * progress, end the drag at the most recent location.
   *
   * @internal
   */
  cancel() {
    // Disposing of a block cancels in-progress drags, but dragging to a delete
    // area disposes of a block and leads to recursive disposal. Break that
    // cycle.
    if (this.isEnding_) {
      return;
    }
    Touch.longStop();
    if (this.dragger) {
      this.dragger.onDragEnd(this.mostRecentEvent, this.currentDragDeltaXY);
    } else if (this.workspaceDragger) {
      this.workspaceDragger.endDrag(this.currentDragDeltaXY);
    }
    this.dispose();
  }

  /**
   * Handle a real or faked right-click event by showing a context menu.
   *
   * @param e A pointerdown event.
   * @internal
   */
  handleRightClick(e: PointerEvent) {
    if (this.targetBlock) {
      this.bringBlockToFront();
      this.targetBlock.workspace.hideChaff(!!this.flyout);
      this.targetBlock.showContextMenu(e);
    } else if (this.startBubble) {
      this.startBubble.showContextMenu(e);
    } else if (this.startComment) {
      this.startComment.workspace.hideChaff();
      this.startComment.showContextMenu(e);
    } else if (this.startWorkspace_ && !this.flyout) {
      this.startWorkspace_.hideChaff();
      this.startWorkspace_.showContextMenu(e);
    }

    // TODO: Handle right-click on a bubble.
    e.preventDefault();
    e.stopPropagation();

    this.dispose();
  }

  /**
   * Handle a pointerdown event on a workspace.
   *
   * @param e A pointerdown event.
   * @param ws The workspace the event hit.
   * @internal
   */
  handleWsStart(e: PointerEvent, ws: WorkspaceSvg) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.handleWsStart, ' +
          'but the gesture had already been started.',
      );
    }
    this.setStartWorkspace(ws);
    this.mostRecentEvent = e;

    if (!this.startBlock && !this.startBubble && !this.startComment) {
      // Selection determines what things start drags. So to drag the workspace,
      // we need to deselect anything that was previously selected.
      common.setSelected(null);
    }

    this.doStart(e);
  }

  /**
   * Fires a workspace click event.
   *
   * @param ws The workspace that a user clicks on.
   */
  private fireWorkspaceClick(ws: WorkspaceSvg) {
    eventUtils.fire(
      new (eventUtils.get(eventUtils.CLICK))(null, ws.id, 'workspace'),
    );
  }

  /**
   * Handle a pointerdown event on a flyout.
   *
   * @param e A pointerdown event.
   * @param flyout The flyout the event hit.
   * @internal
   */
  handleFlyoutStart(e: PointerEvent, flyout: IFlyout) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.handleFlyoutStart, ' +
          'but the gesture had already been started.',
      );
    }
    this.setStartFlyout(flyout);
    this.handleWsStart(e, flyout.getWorkspace());
  }

  /**
   * Handle a pointerdown event on a block.
   *
   * @param e A pointerdown event.
   * @param block The block the event hit.
   * @internal
   */
  handleBlockStart(e: PointerEvent, block: BlockSvg) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.handleBlockStart, ' +
          'but the gesture had already been started.',
      );
    }
    this.setStartBlock(block);
    this.mostRecentEvent = e;
  }

  /**
   * Handle a pointerdown event on a bubble.
   *
   * @param e A pointerdown event.
   * @param bubble The bubble the event hit.
   * @internal
   */
  handleBubbleStart(e: PointerEvent, bubble: IBubble) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.handleBubbleStart, ' +
          'but the gesture had already been started.',
      );
    }
    this.setStartBubble(bubble);
    this.mostRecentEvent = e;
  }

  /**
   * Handle a pointerdown event on a workspace comment.
   *
   * @param e A pointerdown event.
   * @param comment The comment the event hit.
   * @internal
   */
  handleCommentStart(e: PointerEvent, comment: RenderedWorkspaceComment) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.handleCommentStart, ' +
          'but the gesture had already been started.',
      );
    }
    this.setStartComment(comment);
    this.mostRecentEvent = e;
  }

  /* Begin functions defining what actions to take to execute clicks on each
   * type of target.  Any developer wanting to add behaviour on clicks should
   * modify only this code. */

  /** Execute a field click. */
  private doFieldClick() {
    if (!this.startField) {
      throw new Error(
        'Cannot do a field click because the start field is undefined',
      );
    }

    // Only show the editor if the field's editor wasn't already open
    // right before this gesture started.
    const dropdownAlreadyOpen = this.currentDropdownOwner === this.startField;
    if (!dropdownAlreadyOpen) {
      this.startField.showEditor(this.mostRecentEvent);
    }
    this.bringBlockToFront();
  }

  /** Execute an icon click. */
  private doIconClick() {
    if (!this.startIcon) {
      throw new Error(
        'Cannot do an icon click because the start icon is undefined',
      );
    }
    this.bringBlockToFront();
    this.startIcon.onClick();
  }

  /** Execute a block click. */
  private doBlockClick() {
    // Block click in an autoclosing flyout.
    if (this.flyout && this.flyout.autoClose) {
      if (!this.targetBlock) {
        throw new Error(
          'Cannot do a block click because the target block is ' + 'undefined',
        );
      }
      if (this.targetBlock.isEnabled()) {
        if (!eventUtils.getGroup()) {
          eventUtils.setGroup(true);
        }
        const newBlock = this.flyout.createBlock(this.targetBlock);
        newBlock.snapToGrid();
        newBlock.bumpNeighbours();
      }
    } else {
      if (!this.startWorkspace_) {
        throw new Error(
          'Cannot do a block click because the start workspace ' +
            'is undefined',
        );
      }
      // Clicks events are on the start block, even if it was a shadow.
      const event = new (eventUtils.get(eventUtils.CLICK))(
        this.startBlock,
        this.startWorkspace_.id,
        'block',
      );
      eventUtils.fire(event);
    }
    this.bringBlockToFront();
    eventUtils.setGroup(false);
  }

  /**
   * Execute a workspace click. When in accessibility mode shift clicking will
   * move the cursor.
   *
   * @param _e A pointerup event.
   */
  private doWorkspaceClick(_e: PointerEvent) {
    const ws = this.creatorWorkspace;
    if (common.getSelected()) {
      common.getSelected()!.unselect();
    }
    this.fireWorkspaceClick(this.startWorkspace_ || ws);
  }

  /* End functions defining what actions to take to execute clicks on each type
   * of target. */

  // TODO (fenichel): Move bubbles to the front.

  /**
   * Move the dragged/clicked block to the front of the workspace so that it is
   * not occluded by other blocks.
   */
  private bringBlockToFront() {
    // Blocks in the flyout don't overlap, so skip the work.
    if (this.targetBlock && !this.flyout) {
      this.targetBlock.bringToFront();
    }
  }

  /* Begin functions for populating a gesture at pointerdown. */

  /**
   * Record the field that a gesture started on.
   *
   * @param field The field the gesture started on.
   * @internal
   */
  setStartField<T>(field: Field<T>) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.setStartField, ' +
          'but the gesture had already been started.',
      );
    }
    if (!this.startField) {
      this.startField = field as Field;
    }
  }

  /**
   * Record the icon that a gesture started on.
   *
   * @param icon The icon the gesture started on.
   * @internal
   */
  setStartIcon(icon: IIcon) {
    if (this.gestureHasStarted) {
      throw Error(
        'Tried to call gesture.setStartIcon, ' +
          'but the gesture had already been started.',
      );
    }

    if (!this.startIcon) this.startIcon = icon;
  }

  /**
   * Record the bubble that a gesture started on
   *
   * @param bubble The bubble the gesture started on.
   * @internal
   */
  setStartBubble(bubble: IBubble) {
    if (!this.startBubble) {
      this.startBubble = bubble;
    }
  }

  /**
   * Record the comment that a gesture started on
   *
   * @param comment The comment the gesture started on.
   * @internal
   */
  setStartComment(comment: RenderedWorkspaceComment) {
    if (!this.startComment) {
      this.startComment = comment;
    }
  }

  /**
   * Record the block that a gesture started on, and set the target block
   * appropriately.
   *
   * @param block The block the gesture started on.
   * @internal
   */
  setStartBlock(block: BlockSvg) {
    // If the gesture already went through a bubble, don't set the start block.
    if (!this.startBlock && !this.startBubble) {
      this.startBlock = block;
      common.setSelected(this.startBlock);
      if (block.isInFlyout && block !== block.getRootBlock()) {
        this.setTargetBlock(block.getRootBlock());
      } else {
        this.setTargetBlock(block);
      }
    }
  }

  /**
   * Record the block that a gesture targets, meaning the block that will be
   * dragged if this turns into a drag.  If this block is a shadow, that will be
   * its first non-shadow parent.
   *
   * @param block The block the gesture targets.
   */
  private setTargetBlock(block: BlockSvg) {
    if (block.isShadow()) {
      // Non-null assertion is fine b/c it is an invariant that shadows always
      // have parents.
      this.setTargetBlock(block.getParent()!);
    } else {
      this.targetBlock = block;
    }
  }

  /**
   * Record the workspace that a gesture started on.
   *
   * @param ws The workspace the gesture started on.
   */
  private setStartWorkspace(ws: WorkspaceSvg) {
    if (!this.startWorkspace_) {
      this.startWorkspace_ = ws;
    }
  }

  /**
   * Record the flyout that a gesture started on.
   *
   * @param flyout The flyout the gesture started on.
   */
  private setStartFlyout(flyout: IFlyout) {
    if (!this.flyout) {
      this.flyout = flyout;
    }
  }

  /* End functions for populating a gesture at pointerdown. */

  /* Begin helper functions defining types of clicks.  Any developer wanting
   * to change the definition of a click should modify only this code. */

  /**
   * Whether this gesture is a click on a bubble.  This should only be called
   * when ending a gesture (pointerup).
   *
   * @returns Whether this gesture was a click on a bubble.
   */
  private isBubbleClick(): boolean {
    // A bubble click starts on a bubble and never escapes the drag radius.
    const hasStartBubble = !!this.startBubble;
    return hasStartBubble && !this.hasExceededDragRadius;
  }

  private isCommentClick(): boolean {
    return !!this.startComment && !this.hasExceededDragRadius;
  }

  /**
   * Whether this gesture is a click on a block.  This should only be called
   * when ending a gesture (pointerup).
   *
   * @returns Whether this gesture was a click on a block.
   */
  private isBlockClick(): boolean {
    // A block click starts on a block, never escapes the drag radius, and is
    // not a field click.
    const hasStartBlock = !!this.startBlock;
    return (
      hasStartBlock &&
      !this.hasExceededDragRadius &&
      !this.isFieldClick() &&
      !this.isIconClick()
    );
  }

  /**
   * Whether this gesture is a click on a field that should be handled.  This should only be called
   * when ending a gesture (pointerup).
   *
   * @returns Whether this gesture was a click on a field.
   */
  private isFieldClick(): boolean {
    if (!this.startField) return false;
    return (
      this.startField.isClickable() &&
      !this.hasExceededDragRadius &&
      (!this.flyout ||
        this.startField.isClickableInFlyout(this.flyout.autoClose))
    );
  }

  /** @returns Whether this gesture is a click on an icon that should be handled. */
  private isIconClick(): boolean {
    if (!this.startIcon) return false;
    const handleInFlyout =
      !this.flyout ||
      !this.startIcon.isClickableInFlyout ||
      this.startIcon.isClickableInFlyout(this.flyout.autoClose);
    return !this.hasExceededDragRadius && handleInFlyout;
  }

  /**
   * Whether this gesture is a click on a workspace.  This should only be called
   * when ending a gesture (pointerup).
   *
   * @returns Whether this gesture was a click on a workspace.
   */
  private isWorkspaceClick(): boolean {
    const onlyTouchedWorkspace =
      !this.startBlock && !this.startBubble && !this.startField;
    return onlyTouchedWorkspace && !this.hasExceededDragRadius;
  }

  /* End helper functions defining types of clicks. */

  /** Returns the current dragger if the gesture is a drag. */
  getCurrentDragger(): WorkspaceDragger | IDragger | null {
    return this.workspaceDragger ?? this.dragger ?? null;
  }

  /**
   * Whether this gesture is a drag of either a workspace or block.
   * This function is called externally to block actions that cannot be taken
   * mid-drag (e.g. using the keyboard to delete the selected blocks).
   *
   * @returns True if this gesture is a drag of a workspace or block.
   * @internal
   */
  isDragging(): boolean {
    return this.dragging;
  }

  /**
   * Whether this gesture has already been started.  In theory every pointerdown
   * has a corresponding pointerup, but in reality it is possible to lose a
   * pointerup, leaving an in-process gesture hanging.
   *
   * @returns Whether this gesture was a click on a workspace.
   * @internal
   */
  hasStarted(): boolean {
    return this.gestureHasStarted;
  }

  /**
   * Is a drag or other gesture currently in progress on any workspace?
   *
   * @returns True if gesture is occurring.
   */
  static inProgress(): boolean {
    const workspaces = common.getAllWorkspaces();
    for (let i = 0, workspace; (workspace = workspaces[i]); i++) {
      // Not actually necessarily a WorkspaceSvg, but it doesn't matter b/c
      // we're just checking if the property exists. Theoretically we would
      // want to use instanceof, but that causes a circular dependency.
      if ((workspace as WorkspaceSvg).currentGesture_) {
        return true;
      }
    }
    return false;
  }
}