From 7d559367159add4c1b917f9aaa19bce9c17ee55b Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Wed, 27 Mar 2024 22:34:17 +0000 Subject: [PATCH 1/4] feat: add the block drag strategy --- core/dragging/block_drag_strategy.ts | 395 +++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 core/dragging/block_drag_strategy.ts diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts new file mode 100644 index 00000000000..2d6aa9cdf3e --- /dev/null +++ b/core/dragging/block_drag_strategy.ts @@ -0,0 +1,395 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {WorkspaceSvg} from '../workspace_svg.js'; +import {IDragStrategy} from '../interfaces/i_draggable.js'; +import {Coordinate} from '../utils.js'; +import * as eventUtils from '../events/utils.js'; +import {BlockSvg} from '../block_svg.js'; +import {RenderedConnection} from '../rendered_connection.js'; +import * as dom from '../utils/dom.js'; +import * as blockAnimation from '../block_animations.js'; +import {ConnectionType} from '../connection_type.js'; +import * as bumpObjects from '../bump_objects.js'; +import * as registry from '../registry.js'; +import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; +import {Connection} from '../connection.js'; +import type {Block} from '../block.js'; +import {config} from '../config.js'; +import type {BlockMove} from '../events/events_block_move.js'; +import {finishQueuedRenders} from '../render_management.js'; +import * as layers from '../layers.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; + + constructor(private block: BlockSvg) { + this.workspace = block.workspace; + } + + /** Returns true if the block is currently movable. False otherwise. */ + isMovable(): boolean { + return ( + this.block.isOwnMovable() && + !this.block.isShadow() && + !this.block.isDeadOrDying() && + !this.workspace.options.readOnly + ); + } + + /** + * Handles any setup for starting the drag, including disconnecting the block + * from any parent blocks. + */ + startDrag(e?: PointerEvent): void { + this.dragging = true; + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } + this.fireDragStartEvent_(); + + this.startLoc = this.block.getRelativeToSurfaceXY(); + + 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); + } + + /** + * 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(eventUtils.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(eventUtils.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(eventUtils.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 { + 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; + if ( + (local.type === ConnectionType.OUTPUT_VALUE || + local.type === ConnectionType.PREVIOUS_STATEMENT) && + neighbour.isConnected() && + !neighbour.targetBlock()!.isInsertionMarker() && + !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 distance = Coordinate.distance( + Coordinate.sum(localPos, delta), + neighbourPos, + ); + return ( + newCandidate.distance > distance - 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(): void { + 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); + } else { + this.block.queueRender(); + } + 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); + + eventUtils.setGroup(false); + } + + /** 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 { + 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']); + // 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.connectionPreviewer!.hidePreview(); + this.connectionCandidate = null; + + this.block.setDragging(false); + this.dragging = false; + } +} From baff2e86a7f3f378e9d0ad3997046f8f602556ce Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Thu, 28 Mar 2024 22:45:25 +0000 Subject: [PATCH 2/4] chore: remove underscores --- core/dragging/block_drag_strategy.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 2d6aa9cdf3e..5ff14daa8ee 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -78,7 +78,7 @@ export class BlockDragStrategy implements IDragStrategy { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } - this.fireDragStartEvent_(); + this.fireDragStartEvent(); this.startLoc = this.block.getRelativeToSurfaceXY(); @@ -96,8 +96,8 @@ export class BlockDragStrategy implements IDragStrategy { const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey); - if (this.shouldDisconnect_(healStack)) { - this.disconnectBlock_(healStack); + if (this.shouldDisconnect(healStack)) { + this.disconnectBlock(healStack); } this.block.setDragging(true); this.workspace.getLayerManager()?.moveToDragLayer(this.block); @@ -109,7 +109,7 @@ export class BlockDragStrategy implements IDragStrategy { * @param healStack Whether or not to heal the stack after disconnecting. * @returns True to disconnect the block, false otherwise. */ - private shouldDisconnect_(healStack: boolean): boolean { + private shouldDisconnect(healStack: boolean): boolean { return !!( this.block.getParent() || (healStack && @@ -125,7 +125,7 @@ export class BlockDragStrategy implements IDragStrategy { * * @param healStack Whether or not to heal the stack after disconnecting. */ - private disconnectBlock_(healStack: boolean) { + private disconnectBlock(healStack: boolean) { this.startParentConn = this.block.outputConnection?.targetConnection ?? this.block.previousConnection?.targetConnection; @@ -138,7 +138,7 @@ export class BlockDragStrategy implements IDragStrategy { } /** Fire a UI event at the start of a block drag. */ - private fireDragStartEvent_() { + private fireDragStartEvent() { const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))( this.block, true, @@ -148,7 +148,7 @@ export class BlockDragStrategy implements IDragStrategy { } /** Fire a UI event at the end of a block drag. */ - private fireDragEndEvent_() { + private fireDragEndEvent() { const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))( this.block, false, @@ -158,7 +158,7 @@ export class BlockDragStrategy implements IDragStrategy { } /** Fire a move event at the end of a block drag. */ - private fireMoveEvent_() { + private fireMoveEvent() { if (this.block.isDeadOrDying()) return; const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))( this.block, @@ -311,8 +311,8 @@ export class BlockDragStrategy implements IDragStrategy { * connections. */ endDrag(): void { - this.fireDragEndEvent_(); - this.fireMoveEvent_(); + this.fireDragEndEvent(); + this.fireMoveEvent(); dom.stopTextWidthCache(); From f76ec09f4ccc1cba766cc7c58ddfe8b598be2f58 Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Thu, 28 Mar 2024 22:52:11 +0000 Subject: [PATCH 3/4] chore: make crazy condition clearer? --- core/dragging/block_drag_strategy.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 5ff14daa8ee..6722a4b5a09 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -197,12 +197,16 @@ export class BlockDragStrategy implements IDragStrategy { ? 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 ( - (local.type === ConnectionType.OUTPUT_VALUE || - local.type === ConnectionType.PREVIOUS_STATEMENT) && - neighbour.isConnected() && - !neighbour.targetBlock()!.isInsertionMarker() && + localIsOutputOrPrevious && + neighbourIsConnectedToRealBlock && !this.orphanCanConnectAtEnd( draggingBlock, neighbour.targetBlock()!, From b39bc62ae0f8521ea9b7e03eafd5f8a278edf1cf Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Thu, 28 Mar 2024 22:55:29 +0000 Subject: [PATCH 4/4] chore: rename var --- core/dragging/block_drag_strategy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 6722a4b5a09..ea28173b279 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -255,12 +255,12 @@ export class BlockDragStrategy implements IDragStrategy { const {local: currLocal, neighbour: currNeighbour} = currCandiate; const localPos = new Coordinate(currLocal.x, currLocal.y); const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y); - const distance = Coordinate.distance( + const currDistance = Coordinate.distance( Coordinate.sum(localPos, delta), neighbourPos, ); return ( - newCandidate.distance > distance - config.currentConnectionPreference + newCandidate.distance > currDistance - config.currentConnectionPreference ); }