Files
blockly/core/gesture.ts
Christopher Allen b0a7c004a9 refactor(build): Delete Closure Library (#7415)
* fix(build): Restore erroneously-deleted filter function

  This was deleted in PR #7406 as it was mainly being used to
  filter core/ vs. test/mocha/ deps into separate deps files -
  but it turns out also to be used for filtering error
  messages too.  Oops.

* refactor(tests): Migrate advanced compilation test to ES Modules

* refactor(build): Migrate main.js to TypeScript

  This turns out to be pretty straight forward, even if it would
  cause crashing if one actually tried to import this module
  instead of just feeding it to Closure Compiler.

* chore(build): Remove goog.declareModuleId calls

  Replace goog.declareModuleId calls with a comment recording the
  former module ID for posterity (or at least until we decide
  how to reformat the renamings file.

* chore(tests): Delete closure/goog/*

  For the moment we still need something to serve as base.js for
  the benefit of closure-make-deps, so we keep a vestigial
  base.js around, containing only the @provideGoog declaration.

* refactor(build): Remove vestigial base.js

  By changing slightly the command line arguments to
  closure-make-deps and closure-calculate-chunks the need to have
  any base.js is eliminated.

* chore: Typo fix for PR #7415
2023-08-31 00:24:47 +01:00

1264 lines
36 KiB
TypeScript

/**
* @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 {BubbleDragger} from './bubble_dragger.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 {IBlockDragger} from './interfaces/i_block_dragger.js';
import type {IBubble} from './interfaces/i_bubble.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import * as internalConstants from './internal_constants.js';
import * as registry from './registry.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import {Coordinate} from './utils/coordinate.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import {WorkspaceDragger} from './workspace_dragger.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import type {IIcon} from './interfaces/i_icon.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 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[] = [];
/** The object tracking a bubble drag, or null if none is in progress. */
private bubbleDragger: BubbleDragger | null = null;
/** The object tracking a block drag, or null if none is in progress. */
private blockDragger: IBlockDragger | null = null;
/**
* The object tracking a workspace or flyout workspace drag, or null if none
* is in progress.
*/
private workspaceDragger: WorkspaceDragger | null = null;
/** 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;
private healStack: boolean;
/** 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);
/**
* Boolean used to indicate whether or not to heal the stack after
* disconnecting a block.
*/
this.healStack = !internalConstants.DRAG_STACK;
}
/**
* 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.blockDragger) {
this.blockDragger.dispose();
}
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();
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);
this.targetBlock.select();
return true;
}
return false;
}
/**
* Update this gesture to record whether a bubble 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. If a bubble should be dragged this function creates the necessary
* BubbleDragger and starts the drag.
*
* @returns True if a bubble is being dragged.
*/
private updateIsDraggingBubble(): boolean {
if (!this.startBubble) {
return false;
}
this.startDraggingBubble();
return true;
}
/**
* Check whether to start a block drag. If a block should be dragged, either
* from the flyout or in the workspace, create the necessary BlockDragger 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 block should be dragged, either from the flyout or in the
* workspace, this function creates the necessary BlockDragger and starts the
* drag.
*
* @returns True if a block is being dragged.
*/
private updateIsDraggingBlock(): boolean {
if (!this.targetBlock) {
return false;
}
if (this.flyout) {
if (this.updateIsDraggingFromFlyout()) {
this.startDraggingBlock();
return true;
}
} else if (this.targetBlock.isMovable()) {
this.startDraggingBlock();
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.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() {
// Sanity check.
if (this.calledUpdateIsDragging) {
throw Error('updateIsDragging_ should only be called once per gesture.');
}
this.calledUpdateIsDragging = true;
// First check if it was a bubble drag. Bubbles always sit on top of
// blocks.
if (this.updateIsDraggingBubble()) {
return;
}
// Then check if it was a block drag.
if (this.updateIsDraggingBlock()) {
return;
}
// Then check if it's a workspace drag.
this.updateIsDraggingWorkspace();
}
/** Create a block dragger and start dragging the selected block. */
private startDraggingBlock() {
const BlockDraggerClass = registry.getClassFromOptions(
registry.Type.BLOCK_DRAGGER,
this.creatorWorkspace.options,
true,
);
this.blockDragger = new BlockDraggerClass!(
this.targetBlock,
this.startWorkspace_,
);
this.blockDragger!.startDrag(this.currentDragDeltaXY, this.healStack);
this.blockDragger!.drag(this.mostRecentEvent, this.currentDragDeltaXY);
}
/** Create a bubble dragger and start dragging the selected bubble. */
private startDraggingBubble() {
if (!this.startBubble) {
throw new Error(
'Cannot update dragging the bubble because the start ' +
'bubble is undefined',
);
}
if (!this.startWorkspace_) {
throw new Error(
'Cannot update dragging the bubble because the start ' +
'workspace is undefined',
);
}
this.bubbleDragger = new BubbleDragger(
this.startBubble,
this.startWorkspace_,
);
this.bubbleDragger.startBubbleDrag();
this.bubbleDragger.dragBubble(
this.mostRecentEvent,
this.currentDragDeltaXY,
);
}
/**
* 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 (this.targetBlock) {
this.targetBlock.select();
}
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.healStack = e.altKey || e.ctrlKey || e.metaKey;
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.blockDragger) {
this.blockDragger.drag(this.mostRecentEvent, this.currentDragDeltaXY);
} else if (this.bubbleDragger) {
this.bubbleDragger.dragBubble(
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.bubbleDragger) {
this.bubbleDragger.endBubbleDrag(e, this.currentDragDeltaXY);
} else if (this.blockDragger) {
this.blockDragger.endDrag(e, this.currentDragDeltaXY);
} else if (this.workspaceDragger) {
this.workspaceDragger.endDrag(this.currentDragDeltaXY);
} else if (this.isBubbleClick()) {
// Bubbles are in front of all fields and blocks.
this.doBubbleClick();
} 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.bubbleDragger) {
this.bubbleDragger.endBubbleDrag(
this.mostRecentEvent,
this.currentDragDeltaXY,
);
} else if (this.blockDragger) {
this.blockDragger.endDrag(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.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;
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;
}
/* 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 bubble click. */
private doBubbleClick() {
// TODO (#1673): Consistent handling of single clicks.
if (this.startBubble instanceof WorkspaceCommentSvg) {
this.startBubble.setFocus();
this.startBubble.select();
}
}
/** 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.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.scheduleSnapAndBump();
}
} 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 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;
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;
}
/**
* 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. This should only be called
* when ending a gesture (pointerup).
*
* @returns Whether this gesture was a click on a field.
*/
private isFieldClick(): boolean {
const fieldClickable = this.startField
? this.startField.isClickable()
: false;
return (
fieldClickable &&
!this.hasExceededDragRadius &&
(!this.flyout || !this.flyout.autoClose)
);
}
/** @returns Whether this gesture is a click on an icon. */
private isIconClick(): boolean {
return !!this.startIcon && !this.hasExceededDragRadius;
}
/**
* 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. */
/**
* 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.workspaceDragger || !!this.blockDragger || !!this.bubbleDragger
);
}
/**
* 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;
}
/**
* Get a list of the insertion markers that currently exist. Block drags have
* 0, 1, or 2 insertion markers.
*
* @returns A possibly empty list of insertion marker blocks.
* @internal
*/
getInsertionMarkers(): BlockSvg[] {
if (this.blockDragger) {
return this.blockDragger.getInsertionMarkers();
}
return [];
}
/**
* Gets the current dragger if an item is being dragged. Null if nothing is
* being dragged.
*
* @returns The dragger that is currently in use or null if no drag is in
* progress.
*/
getCurrentDragger(): WorkspaceDragger | BubbleDragger | IBlockDragger | null {
return this.blockDragger ?? this.workspaceDragger ?? this.bubbleDragger;
}
/**
* 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;
}
}