Files
blockly/core/dragging/block_drag_strategy.ts
Mateus S. Pereira 7219800183 fix: workspace shifts when deleting a block (#8666)
* fix: workspace shifts when deleting a block

* fix: awaiting for block rerender

* fix: create reusable method to prevent marking method as async
2024-12-04 09:25:01 -08:00

467 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {Block} from '../block.js';
import * as blockAnimation from '../block_animations.js';
import {BlockSvg} from '../block_svg.js';
import * as bumpObjects from '../bump_objects.js';
import {config} from '../config.js';
import {Connection} from '../connection.js';
import {ConnectionType} from '../connection_type.js';
import type {BlockMove} from '../events/events_block_move.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js';
import * as registry from '../registry.js';
import {finishQueuedRenders} from '../render_management.js';
import {RenderedConnection} from '../rendered_connection.js';
import {Coordinate} from '../utils.js';
import * as dom from '../utils/dom.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/** Represents a nearby valid connection. */
interface ConnectionCandidate {
/** A connection on the dragging stack that is compatible with neighbour. */
local: RenderedConnection;
/** A nearby connection that is compatible with local. */
neighbour: RenderedConnection;
/** The distance between the local connection and the neighbour connection. */
distance: number;
}
export class BlockDragStrategy implements IDragStrategy {
private workspace: WorkspaceSvg;
/** The parent block at the start of the drag. */
private startParentConn: RenderedConnection | null = null;
/**
* The child block at the start of the drag. Only gets set if
* `healStack` is true.
*/
private startChildConn: RenderedConnection | null = null;
private startLoc: Coordinate | null = null;
private connectionCandidate: ConnectionCandidate | null = null;
private connectionPreviewer: IConnectionPreviewer | null = null;
private dragging = false;
/**
* If this is a shadow block, the offset between this block and the parent
* block, to add to the drag location. In workspace units.
*/
private dragOffset = new Coordinate(0, 0);
/** Was there already an event group in progress when the drag started? */
private inGroup: boolean = false;
constructor(private block: BlockSvg) {
this.workspace = block.workspace;
}
/** Returns true if the block is currently movable. False otherwise. */
isMovable(): boolean {
if (this.block.isShadow()) {
return this.block.getParent()?.isMovable() ?? false;
}
return (
this.block.isOwnMovable() &&
!this.block.isDeadOrDying() &&
!this.workspace.options.readOnly &&
// We never drag blocks in the flyout, only create new blocks that are
// dragged.
!this.block.isInFlyout
);
}
/**
* Handles any setup for starting the drag, including disconnecting the block
* from any parent blocks.
*/
startDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.startDraggingShadow(e);
return;
}
this.dragging = true;
this.inGroup = !!eventUtils.getGroup();
if (!this.inGroup) {
eventUtils.setGroup(true);
}
this.fireDragStartEvent();
this.startLoc = this.block.getRelativeToSurfaceXY();
this.connectionCandidate = null;
const previewerConstructor = registry.getClassFromOptions(
registry.Type.CONNECTION_PREVIEWER,
this.workspace.options,
);
this.connectionPreviewer = new previewerConstructor!(this.block);
// During a drag there may be a lot of rerenders, but not field changes.
// Turn the cache on so we don't do spurious remeasures during the drag.
dom.startTextWidthCache();
this.workspace.setResizesEnabled(false);
blockAnimation.disconnectUiStop();
const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey);
if (this.shouldDisconnect(healStack)) {
this.disconnectBlock(healStack);
}
this.block.setDragging(true);
this.workspace.getLayerManager()?.moveToDragLayer(this.block);
}
/** Starts a drag on a shadow, recording the drag offset. */
private startDraggingShadow(e?: PointerEvent) {
const parent = this.block.getParent();
if (!parent) {
throw new Error(
'Tried to drag a shadow block with no parent. ' +
'Shadow blocks should always have parents.',
);
}
this.dragOffset = Coordinate.difference(
parent.getRelativeToSurfaceXY(),
this.block.getRelativeToSurfaceXY(),
);
parent.startDrag(e);
}
/**
* Whether or not we should disconnect the block when a drag is started.
*
* @param healStack Whether or not to heal the stack after disconnecting.
* @returns True to disconnect the block, false otherwise.
*/
private shouldDisconnect(healStack: boolean): boolean {
return !!(
this.block.getParent() ||
(healStack &&
this.block.nextConnection &&
this.block.nextConnection.targetBlock())
);
}
/**
* Disconnects the block from any parents. If `healStack` is true and this is
* a stack block, we also disconnect from any next blocks and attempt to
* attach them to any parent.
*
* @param healStack Whether or not to heal the stack after disconnecting.
*/
private disconnectBlock(healStack: boolean) {
this.startParentConn =
this.block.outputConnection?.targetConnection ??
this.block.previousConnection?.targetConnection;
if (healStack) {
this.startChildConn = this.block.nextConnection?.targetConnection;
}
this.block.unplug(healStack);
blockAnimation.disconnectUiEffect(this.block);
}
/** Fire a UI event at the start of a block drag. */
private fireDragStartEvent() {
const event = new (eventUtils.get(EventType.BLOCK_DRAG))(
this.block,
true,
this.block.getDescendants(false),
);
eventUtils.fire(event);
}
/** Fire a UI event at the end of a block drag. */
private fireDragEndEvent() {
const event = new (eventUtils.get(EventType.BLOCK_DRAG))(
this.block,
false,
this.block.getDescendants(false),
);
eventUtils.fire(event);
}
/** Fire a move event at the end of a block drag. */
private fireMoveEvent() {
if (this.block.isDeadOrDying()) return;
const event = new (eventUtils.get(EventType.BLOCK_MOVE))(
this.block,
) as BlockMove;
event.setReason(['drag']);
event.oldCoordinate = this.startLoc!;
event.recordNew();
eventUtils.fire(event);
}
/** Moves the block and updates any connection previews. */
drag(newLoc: Coordinate): void {
if (this.block.isShadow()) {
this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset));
return;
}
this.block.moveDuringDrag(newLoc);
this.updateConnectionPreview(
this.block,
Coordinate.difference(newLoc, this.startLoc!),
);
}
/**
* @param draggingBlock The block being dragged.
* @param delta How far the pointer has moved from the position
* at the start of the drag, in workspace units.
*/
private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) {
const currCandidate = this.connectionCandidate;
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
if (!newCandidate) {
this.connectionPreviewer!.hidePreview();
this.connectionCandidate = null;
return;
}
const candidate =
currCandidate &&
this.currCandidateIsBetter(currCandidate, delta, newCandidate)
? currCandidate
: newCandidate;
this.connectionCandidate = candidate;
const {local, neighbour} = candidate;
const localIsOutputOrPrevious =
local.type === ConnectionType.OUTPUT_VALUE ||
local.type === ConnectionType.PREVIOUS_STATEMENT;
const neighbourIsConnectedToRealBlock =
neighbour.isConnected() && !neighbour.targetBlock()!.isInsertionMarker();
if (
localIsOutputOrPrevious &&
neighbourIsConnectedToRealBlock &&
!this.orphanCanConnectAtEnd(
draggingBlock,
neighbour.targetBlock()!,
local.type,
)
) {
this.connectionPreviewer!.previewReplacement(
local,
neighbour,
neighbour.targetBlock()!,
);
return;
}
this.connectionPreviewer!.previewConnection(local, neighbour);
}
/**
* Returns true if the given orphan block can connect at the end of the
* top block's stack or row, false otherwise.
*/
private orphanCanConnectAtEnd(
topBlock: BlockSvg,
orphanBlock: BlockSvg,
localType: number,
): boolean {
const orphanConnection =
localType === ConnectionType.OUTPUT_VALUE
? orphanBlock.outputConnection
: orphanBlock.previousConnection;
return !!Connection.getConnectionForOrphanedConnection(
topBlock as Block,
orphanConnection as Connection,
);
}
/**
* Returns true if the current candidate is better than the new candidate.
*
* We slightly prefer the current candidate even if it is farther away.
*/
private currCandidateIsBetter(
currCandiate: ConnectionCandidate,
delta: Coordinate,
newCandidate: ConnectionCandidate,
): boolean {
const {local: currLocal, neighbour: currNeighbour} = currCandiate;
const localPos = new Coordinate(currLocal.x, currLocal.y);
const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y);
const currDistance = Coordinate.distance(
Coordinate.sum(localPos, delta),
neighbourPos,
);
return (
newCandidate.distance > currDistance - config.currentConnectionPreference
);
}
/**
* Returns the closest valid candidate connection, if one can be found.
*
* Valid neighbour connections are within the configured start radius, with a
* compatible type (input, output, etc) and connection check.
*/
private getConnectionCandidate(
draggingBlock: BlockSvg,
delta: Coordinate,
): ConnectionCandidate | null {
const localConns = this.getLocalConnections(draggingBlock);
let radius = this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
let candidate = null;
for (const conn of localConns) {
const {connection: neighbour, radius: rad} = conn.closest(radius, delta);
if (neighbour) {
candidate = {
local: conn,
neighbour: neighbour,
distance: rad,
};
radius = rad;
}
}
return candidate;
}
/**
* Returns all of the connections we might connect to blocks on the workspace.
*
* Includes any connections on the dragging block, and any last next
* connection on the stack (if one exists).
*/
private getLocalConnections(draggingBlock: BlockSvg): RenderedConnection[] {
const available = draggingBlock.getConnections_(false);
const lastOnStack = draggingBlock.lastConnectionInStack(true);
if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) {
available.push(lastOnStack);
}
return available;
}
/**
* Cleans up any state at the end of the drag. Applies any pending
* connections.
*/
endDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.block.getParent()?.endDrag(e);
return;
}
this.fireDragEndEvent();
this.fireMoveEvent();
dom.stopTextWidthCache();
blockAnimation.disconnectUiStop();
this.connectionPreviewer!.hidePreview();
if (!this.block.isDeadOrDying() && this.dragging) {
// These are expensive and don't need to be done if we're deleting, or
// if we've already stopped dragging because we moved back to the start.
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.block, layers.BLOCK);
this.block.setDragging(false);
}
if (this.connectionCandidate) {
// Applying connections also rerenders the relevant blocks.
this.applyConnections(this.connectionCandidate);
this.disposeStep();
} else {
this.block.queueRender().then(() => this.disposeStep());
}
if (!this.inGroup) {
eventUtils.setGroup(false);
}
}
/** Disposes of any state at the end of the drag. */
private disposeStep() {
this.block.snapToGrid();
// Must dispose after connections are applied to not break the dynamic
// connections plugin. See #7859
this.connectionPreviewer!.dispose();
this.workspace.setResizesEnabled(true);
}
/** Connects the given candidate connections. */
private applyConnections(candidate: ConnectionCandidate) {
const {local, neighbour} = candidate;
local.connect(neighbour);
const inferiorConnection = local.isSuperior() ? neighbour : local;
const rootBlock = this.block.getRootBlock();
finishQueuedRenders().then(() => {
blockAnimation.connectionUiEffect(inferiorConnection.getSourceBlock());
// bringToFront is incredibly expensive. Delay until the next frame.
setTimeout(() => {
rootBlock.bringToFront();
}, 0);
});
}
/**
* Moves the block back to where it was at the beginning of the drag,
* including reconnecting connections.
*/
revertDrag(): void {
if (this.block.isShadow()) {
this.block.getParent()?.revertDrag();
return;
}
this.startChildConn?.connect(this.block.nextConnection);
if (this.startParentConn) {
switch (this.startParentConn.type) {
case ConnectionType.INPUT_VALUE:
this.startParentConn.connect(this.block.outputConnection);
break;
case ConnectionType.NEXT_STATEMENT:
this.startParentConn.connect(this.block.previousConnection);
}
} else {
this.block.moveTo(this.startLoc!, ['drag']);
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.block, layers.BLOCK);
// Blocks dragged directly from a flyout may need to be bumped into
// bounds.
bumpObjects.bumpIntoBounds(
this.workspace,
this.workspace.getMetricsManager().getScrollMetrics(true),
this.block,
);
}
this.startChildConn = null;
this.startParentConn = null;
this.connectionPreviewer!.hidePreview();
this.connectionCandidate = null;
this.block.setDragging(false);
this.dragging = false;
}
}