Skip to content

Commit 7d55936

Browse files
committed
feat: add the block drag strategy
1 parent da79a12 commit 7d55936

File tree

1 file changed

+395
-0
lines changed

1 file changed

+395
-0
lines changed
Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {WorkspaceSvg} from '../workspace_svg.js';
8+
import {IDragStrategy} from '../interfaces/i_draggable.js';
9+
import {Coordinate} from '../utils.js';
10+
import * as eventUtils from '../events/utils.js';
11+
import {BlockSvg} from '../block_svg.js';
12+
import {RenderedConnection} from '../rendered_connection.js';
13+
import * as dom from '../utils/dom.js';
14+
import * as blockAnimation from '../block_animations.js';
15+
import {ConnectionType} from '../connection_type.js';
16+
import * as bumpObjects from '../bump_objects.js';
17+
import * as registry from '../registry.js';
18+
import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js';
19+
import {Connection} from '../connection.js';
20+
import type {Block} from '../block.js';
21+
import {config} from '../config.js';
22+
import type {BlockMove} from '../events/events_block_move.js';
23+
import {finishQueuedRenders} from '../render_management.js';
24+
import * as layers from '../layers.js';
25+
26+
/** Represents a nearby valid connection. */
27+
interface ConnectionCandidate {
28+
/** A connection on the dragging stack that is compatible with neighbour. */
29+
local: RenderedConnection;
30+
31+
/** A nearby connection that is compatible with local. */
32+
neighbour: RenderedConnection;
33+
34+
/** The distance between the local connection and the neighbour connection. */
35+
distance: number;
36+
}
37+
38+
export class BlockDragStrategy implements IDragStrategy {
39+
private workspace: WorkspaceSvg;
40+
41+
/** The parent block at the start of the drag. */
42+
private startParentConn: RenderedConnection | null = null;
43+
44+
/**
45+
* The child block at the start of the drag. Only gets set if
46+
* `healStack` is true.
47+
*/
48+
private startChildConn: RenderedConnection | null = null;
49+
50+
private startLoc: Coordinate | null = null;
51+
52+
private connectionCandidate: ConnectionCandidate | null = null;
53+
54+
private connectionPreviewer: IConnectionPreviewer | null = null;
55+
56+
private dragging = false;
57+
58+
constructor(private block: BlockSvg) {
59+
this.workspace = block.workspace;
60+
}
61+
62+
/** Returns true if the block is currently movable. False otherwise. */
63+
isMovable(): boolean {
64+
return (
65+
this.block.isOwnMovable() &&
66+
!this.block.isShadow() &&
67+
!this.block.isDeadOrDying() &&
68+
!this.workspace.options.readOnly
69+
);
70+
}
71+
72+
/**
73+
* Handles any setup for starting the drag, including disconnecting the block
74+
* from any parent blocks.
75+
*/
76+
startDrag(e?: PointerEvent): void {
77+
this.dragging = true;
78+
if (!eventUtils.getGroup()) {
79+
eventUtils.setGroup(true);
80+
}
81+
this.fireDragStartEvent_();
82+
83+
this.startLoc = this.block.getRelativeToSurfaceXY();
84+
85+
const previewerConstructor = registry.getClassFromOptions(
86+
registry.Type.CONNECTION_PREVIEWER,
87+
this.workspace.options,
88+
);
89+
this.connectionPreviewer = new previewerConstructor!(this.block);
90+
91+
// During a drag there may be a lot of rerenders, but not field changes.
92+
// Turn the cache on so we don't do spurious remeasures during the drag.
93+
dom.startTextWidthCache();
94+
this.workspace.setResizesEnabled(false);
95+
blockAnimation.disconnectUiStop();
96+
97+
const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey);
98+
99+
if (this.shouldDisconnect_(healStack)) {
100+
this.disconnectBlock_(healStack);
101+
}
102+
this.block.setDragging(true);
103+
this.workspace.getLayerManager()?.moveToDragLayer(this.block);
104+
}
105+
106+
/**
107+
* Whether or not we should disconnect the block when a drag is started.
108+
*
109+
* @param healStack Whether or not to heal the stack after disconnecting.
110+
* @returns True to disconnect the block, false otherwise.
111+
*/
112+
private shouldDisconnect_(healStack: boolean): boolean {
113+
return !!(
114+
this.block.getParent() ||
115+
(healStack &&
116+
this.block.nextConnection &&
117+
this.block.nextConnection.targetBlock())
118+
);
119+
}
120+
121+
/**
122+
* Disconnects the block from any parents. If `healStack` is true and this is
123+
* a stack block, we also disconnect from any next blocks and attempt to
124+
* attach them to any parent.
125+
*
126+
* @param healStack Whether or not to heal the stack after disconnecting.
127+
*/
128+
private disconnectBlock_(healStack: boolean) {
129+
this.startParentConn =
130+
this.block.outputConnection?.targetConnection ??
131+
this.block.previousConnection?.targetConnection;
132+
if (healStack) {
133+
this.startChildConn = this.block.nextConnection?.targetConnection;
134+
}
135+
136+
this.block.unplug(healStack);
137+
blockAnimation.disconnectUiEffect(this.block);
138+
}
139+
140+
/** Fire a UI event at the start of a block drag. */
141+
private fireDragStartEvent_() {
142+
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
143+
this.block,
144+
true,
145+
this.block.getDescendants(false),
146+
);
147+
eventUtils.fire(event);
148+
}
149+
150+
/** Fire a UI event at the end of a block drag. */
151+
private fireDragEndEvent_() {
152+
const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
153+
this.block,
154+
false,
155+
this.block.getDescendants(false),
156+
);
157+
eventUtils.fire(event);
158+
}
159+
160+
/** Fire a move event at the end of a block drag. */
161+
private fireMoveEvent_() {
162+
if (this.block.isDeadOrDying()) return;
163+
const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(
164+
this.block,
165+
) as BlockMove;
166+
event.setReason(['drag']);
167+
event.oldCoordinate = this.startLoc!;
168+
event.recordNew();
169+
eventUtils.fire(event);
170+
}
171+
172+
/** Moves the block and updates any connection previews. */
173+
drag(newLoc: Coordinate): void {
174+
this.block.moveDuringDrag(newLoc);
175+
this.updateConnectionPreview(
176+
this.block,
177+
Coordinate.difference(newLoc, this.startLoc!),
178+
);
179+
}
180+
181+
/**
182+
* @param draggingBlock The block being dragged.
183+
* @param delta How far the pointer has moved from the position
184+
* at the start of the drag, in workspace units.
185+
*/
186+
private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) {
187+
const currCandidate = this.connectionCandidate;
188+
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
189+
if (!newCandidate) {
190+
this.connectionPreviewer!.hidePreview();
191+
this.connectionCandidate = null;
192+
return;
193+
}
194+
const candidate =
195+
currCandidate &&
196+
this.currCandidateIsBetter(currCandidate, delta, newCandidate)
197+
? currCandidate
198+
: newCandidate;
199+
this.connectionCandidate = candidate;
200+
const {local, neighbour} = candidate;
201+
if (
202+
(local.type === ConnectionType.OUTPUT_VALUE ||
203+
local.type === ConnectionType.PREVIOUS_STATEMENT) &&
204+
neighbour.isConnected() &&
205+
!neighbour.targetBlock()!.isInsertionMarker() &&
206+
!this.orphanCanConnectAtEnd(
207+
draggingBlock,
208+
neighbour.targetBlock()!,
209+
local.type,
210+
)
211+
) {
212+
this.connectionPreviewer!.previewReplacement(
213+
local,
214+
neighbour,
215+
neighbour.targetBlock()!,
216+
);
217+
return;
218+
}
219+
this.connectionPreviewer!.previewConnection(local, neighbour);
220+
}
221+
222+
/**
223+
* Returns true if the given orphan block can connect at the end of the
224+
* top block's stack or row, false otherwise.
225+
*/
226+
private orphanCanConnectAtEnd(
227+
topBlock: BlockSvg,
228+
orphanBlock: BlockSvg,
229+
localType: number,
230+
): boolean {
231+
const orphanConnection =
232+
localType === ConnectionType.OUTPUT_VALUE
233+
? orphanBlock.outputConnection
234+
: orphanBlock.previousConnection;
235+
return !!Connection.getConnectionForOrphanedConnection(
236+
topBlock as Block,
237+
orphanConnection as Connection,
238+
);
239+
}
240+
241+
/**
242+
* Returns true if the current candidate is better than the new candidate.
243+
*
244+
* We slightly prefer the current candidate even if it is farther away.
245+
*/
246+
private currCandidateIsBetter(
247+
currCandiate: ConnectionCandidate,
248+
delta: Coordinate,
249+
newCandidate: ConnectionCandidate,
250+
): boolean {
251+
const {local: currLocal, neighbour: currNeighbour} = currCandiate;
252+
const localPos = new Coordinate(currLocal.x, currLocal.y);
253+
const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y);
254+
const distance = Coordinate.distance(
255+
Coordinate.sum(localPos, delta),
256+
neighbourPos,
257+
);
258+
return (
259+
newCandidate.distance > distance - config.currentConnectionPreference
260+
);
261+
}
262+
263+
/**
264+
* Returns the closest valid candidate connection, if one can be found.
265+
*
266+
* Valid neighbour connections are within the configured start radius, with a
267+
* compatible type (input, output, etc) and connection check.
268+
*/
269+
private getConnectionCandidate(
270+
draggingBlock: BlockSvg,
271+
delta: Coordinate,
272+
): ConnectionCandidate | null {
273+
const localConns = this.getLocalConnections(draggingBlock);
274+
let radius = this.connectionCandidate
275+
? config.connectingSnapRadius
276+
: config.snapRadius;
277+
let candidate = null;
278+
279+
for (const conn of localConns) {
280+
const {connection: neighbour, radius: rad} = conn.closest(radius, delta);
281+
if (neighbour) {
282+
candidate = {
283+
local: conn,
284+
neighbour: neighbour,
285+
distance: rad,
286+
};
287+
radius = rad;
288+
}
289+
}
290+
291+
return candidate;
292+
}
293+
294+
/**
295+
* Returns all of the connections we might connect to blocks on the workspace.
296+
*
297+
* Includes any connections on the dragging block, and any last next
298+
* connection on the stack (if one exists).
299+
*/
300+
private getLocalConnections(draggingBlock: BlockSvg): RenderedConnection[] {
301+
const available = draggingBlock.getConnections_(false);
302+
const lastOnStack = draggingBlock.lastConnectionInStack(true);
303+
if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) {
304+
available.push(lastOnStack);
305+
}
306+
return available;
307+
}
308+
309+
/**
310+
* Cleans up any state at the end of the drag. Applies any pending
311+
* connections.
312+
*/
313+
endDrag(): void {
314+
this.fireDragEndEvent_();
315+
this.fireMoveEvent_();
316+
317+
dom.stopTextWidthCache();
318+
319+
blockAnimation.disconnectUiStop();
320+
this.connectionPreviewer!.hidePreview();
321+
322+
if (!this.block.isDeadOrDying() && this.dragging) {
323+
// These are expensive and don't need to be done if we're deleting, or
324+
// if we've already stopped dragging because we moved back to the start.
325+
this.workspace
326+
.getLayerManager()
327+
?.moveOffDragLayer(this.block, layers.BLOCK);
328+
this.block.setDragging(false);
329+
}
330+
331+
if (this.connectionCandidate) {
332+
// Applying connections also rerenders the relevant blocks.
333+
this.applyConnections(this.connectionCandidate);
334+
} else {
335+
this.block.queueRender();
336+
}
337+
this.block.snapToGrid();
338+
339+
// Must dispose after connections are applied to not break the dynamic
340+
// connections plugin. See #7859
341+
this.connectionPreviewer!.dispose();
342+
this.workspace.setResizesEnabled(true);
343+
344+
eventUtils.setGroup(false);
345+
}
346+
347+
/** Connects the given candidate connections. */
348+
private applyConnections(candidate: ConnectionCandidate) {
349+
const {local, neighbour} = candidate;
350+
local.connect(neighbour);
351+
352+
const inferiorConnection = local.isSuperior() ? neighbour : local;
353+
const rootBlock = this.block.getRootBlock();
354+
355+
finishQueuedRenders().then(() => {
356+
blockAnimation.connectionUiEffect(inferiorConnection.getSourceBlock());
357+
// bringToFront is incredibly expensive. Delay until the next frame.
358+
setTimeout(() => {
359+
rootBlock.bringToFront();
360+
}, 0);
361+
});
362+
}
363+
364+
/**
365+
* Moves the block back to where it was at the beginning of the drag,
366+
* including reconnecting connections.
367+
*/
368+
revertDrag(): void {
369+
this.startChildConn?.connect(this.block.nextConnection);
370+
if (this.startParentConn) {
371+
switch (this.startParentConn.type) {
372+
case ConnectionType.INPUT_VALUE:
373+
this.startParentConn.connect(this.block.outputConnection);
374+
break;
375+
case ConnectionType.NEXT_STATEMENT:
376+
this.startParentConn.connect(this.block.previousConnection);
377+
}
378+
} else {
379+
this.block.moveTo(this.startLoc!, ['drag']);
380+
// Blocks dragged directly from a flyout may need to be bumped into
381+
// bounds.
382+
bumpObjects.bumpIntoBounds(
383+
this.workspace,
384+
this.workspace.getMetricsManager().getScrollMetrics(true),
385+
this.block,
386+
);
387+
}
388+
389+
this.connectionPreviewer!.hidePreview();
390+
this.connectionCandidate = null;
391+
392+
this.block.setDragging(false);
393+
this.dragging = false;
394+
}
395+
}

0 commit comments

Comments
 (0)