mirror of
https://github.com/google/blockly.git
synced 2026-01-04 23:50:12 +01:00
* fix: workspace shifts when deleting a block * fix: awaiting for block rerender * fix: create reusable method to prevent marking method as async
467 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|