Skip to content

Commit 841ae3c

Browse files
feat: introduce Mover actions + experimental DragMover implementation (#352)
* feat: Introduce Mover class, moving mode Introduce the Mover class, with shortcuts that will put a workspace into (and exit from) moving mode. While in moving mode, cursor navigation via the cursor keys is disabled. * feat: Save block starting location and connections * feat: Experiment: unconstrained movement using Dragger Create an experimental implementation of unconstrained dragging using the existing Dragger class. Implement this on a new Mover subclass DragMover, to keep this experimental code (that will probably be thrown away) separate from the main Mover implementation. Known bugs: * Blocks lurch around somewhat wildly while being moved. * Moving top-level blocks works reasonably well, but attempting to move a non-top-level block will result in it being deleted unless the move is aborted. * feat: Add Move Block (M) context menu item * fix: Allow ctrl+arrows to also perform unconstrained moves Alt+arrows causes browser navigation on Windows. :-( * fix: Use better fake PointerEvents to prevent random deletions There was a bug that would often cause blocks to be deleted. This was because the dragger thought the block was on the toolbox, which is a delete area. Fix this by giving the dragger much better fake PointerEvents, so that it knows where the block actually is - good enough that you can move the block to the trash can and it will be deleted! * chore: address feedback from #317 * chore: lint and format * fix: remove allowCollision for m * fix: #356 by hiding the connection preview before reverting a drag * fix: spelling --------- Co-authored-by: Christopher Allen <cpcallen+git@google.com>
1 parent 0ed9f59 commit 841ae3c

File tree

3 files changed

+548
-4
lines changed

3 files changed

+548
-4
lines changed

src/actions/drag_mover.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ASTNode,
9+
BlockSvg,
10+
WorkspaceSvg,
11+
common,
12+
registry,
13+
utils,
14+
} from 'blockly';
15+
import type {Block, IDragger} from 'blockly';
16+
import {Mover, MoveInfo} from './mover';
17+
18+
/**
19+
* The distance to move an item, in workspace coordinates, when
20+
* making an unconstrained move.
21+
*/
22+
const UNCONSTRAINED_MOVE_DISTANCE = 20;
23+
24+
/**
25+
* An experimental implementation of Mover that uses a dragger to
26+
* perform unconstrained movement.
27+
*/
28+
export class DragMover extends Mover {
29+
/**
30+
* Map of moves in progress.
31+
*
32+
* An entry for a given workspace in this map means that the this
33+
* Mover is moving a block on that workspace, and will disable
34+
* normal cursor movement until the move is complete.
35+
*/
36+
protected declare moves: Map<WorkspaceSvg, DragMoveInfo>;
37+
38+
/**
39+
* Start moving the currently-focused item on workspace, if
40+
* possible.
41+
*
42+
* Should only be called if canMove has returned true.
43+
*
44+
* @param workspace The workspace we might be moving on.
45+
* @returns True iff a move has successfully begun.
46+
*/
47+
override startMove(workspace: WorkspaceSvg) {
48+
const cursor = workspace?.getCursor();
49+
const block = this.getCurrentBlock(workspace);
50+
if (!cursor || !block) throw new Error('precondition failure');
51+
52+
// Select and focus block.
53+
common.setSelected(block);
54+
cursor.setCurNode(ASTNode.createBlockNode(block));
55+
// Begin dragging block.
56+
const DraggerClass = registry.getClassFromOptions(
57+
registry.Type.BLOCK_DRAGGER,
58+
workspace.options,
59+
true,
60+
);
61+
if (!DraggerClass) throw new Error('no Dragger registered');
62+
const dragger = new DraggerClass(block, workspace);
63+
// Record that a move is in progress and start dragging.
64+
const info = new DragMoveInfo(block, dragger);
65+
this.moves.set(workspace, info);
66+
// Begin drag.
67+
dragger.onDragStart(info.fakePointerEvent('pointerdown'));
68+
return true;
69+
}
70+
71+
/**
72+
* Finish moving the currently-focused item on workspace.
73+
*
74+
* Should only be called if isMoving has returned true.
75+
*
76+
* @param workspace The workspace on which we are moving.
77+
* @returns True iff move successfully finished.
78+
*/
79+
override finishMove(workspace: WorkspaceSvg) {
80+
const info = this.moves.get(workspace);
81+
if (!info) throw new Error('no move info for workspace');
82+
83+
info.dragger.onDragEnd(
84+
info.fakePointerEvent('pointerup'),
85+
new utils.Coordinate(0, 0),
86+
);
87+
88+
this.moves.delete(workspace);
89+
return true;
90+
}
91+
92+
/**
93+
* Abort moving the currently-focused item on workspace.
94+
*
95+
* Should only be called if isMoving has returned true.
96+
*
97+
* @param workspace The workspace on which we are moving.
98+
* @returns True iff move successfully aborted.
99+
*/
100+
override abortMove(workspace: WorkspaceSvg) {
101+
const info = this.moves.get(workspace);
102+
if (!info) throw new Error('no move info for workspace');
103+
104+
// Monkey patch dragger to trigger call to draggable.revertDrag.
105+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
106+
(info.dragger as any).shouldReturnToStart = () => true;
107+
const blockSvg = info.block as BlockSvg;
108+
109+
// Explicitly call `hidePreview` because it is not called in revertDrag.
110+
// @ts-expect-error Access to private property dragStrategy.
111+
blockSvg.dragStrategy.connectionPreviewer.hidePreview();
112+
info.dragger.onDragEnd(
113+
info.fakePointerEvent('pointerup'),
114+
new utils.Coordinate(0, 0),
115+
);
116+
117+
this.moves.delete(workspace);
118+
return true;
119+
}
120+
121+
/**
122+
* Action to move the item being moved in the given direction,
123+
* constrained to valid attachment points (if any).
124+
*
125+
* @param workspace The workspace to move on.
126+
* @returns True iff this action applies and has been performed.
127+
*/
128+
override moveConstrained(
129+
workspace: WorkspaceSvg,
130+
/* ... */
131+
) {
132+
// Not yet implemented. Absorb keystroke to avoid moving cursor.
133+
alert(`Constrained movement not implemented.
134+
135+
Use ctrl+arrow or alt+arrow (option+arrow on macOS) for unconstrained move.
136+
Use enter to complete the move, or escape to abort.`);
137+
return true;
138+
}
139+
140+
/**
141+
* Action to move the item being moved in the given direction,
142+
* without constraint.
143+
*
144+
* @param workspace The workspace to move on.
145+
* @param xDirection -1 to move left. 1 to move right.
146+
* @param yDirection -1 to move up. 1 to move down.
147+
* @returns True iff this action applies and has been performed.
148+
*/
149+
override moveUnconstrained(
150+
workspace: WorkspaceSvg,
151+
xDirection: number,
152+
yDirection: number,
153+
): boolean {
154+
if (!workspace) return false;
155+
const info = this.moves.get(workspace);
156+
if (!info) throw new Error('no move info for workspace');
157+
158+
info.totalDelta.x +=
159+
xDirection * UNCONSTRAINED_MOVE_DISTANCE * workspace.scale;
160+
info.totalDelta.y +=
161+
yDirection * UNCONSTRAINED_MOVE_DISTANCE * workspace.scale;
162+
163+
info.dragger.onDrag(info.fakePointerEvent('pointermove'), info.totalDelta);
164+
return true;
165+
}
166+
}
167+
168+
/**
169+
* Information about the currently in-progress move for a given
170+
* Workspace.
171+
*/
172+
class DragMoveInfo extends MoveInfo {
173+
/** Total distance moved, in screen pixels */
174+
totalDelta = new utils.Coordinate(0, 0);
175+
176+
constructor(
177+
readonly block: Block,
178+
readonly dragger: IDragger,
179+
) {
180+
super(block);
181+
}
182+
183+
/**
184+
* Create a fake pointer event for dragging.
185+
*
186+
* @param type Which type of pointer event to create.
187+
* @returns A synthetic PointerEvent that can be consumed by Blockly's
188+
* dragging code.
189+
*/
190+
fakePointerEvent(type: string): PointerEvent {
191+
const workspace = this.block.workspace;
192+
if (!(workspace instanceof WorkspaceSvg)) throw new TypeError();
193+
194+
const blockCoords = utils.svgMath.wsToScreenCoordinates(
195+
workspace,
196+
new utils.Coordinate(
197+
this.startLocation.x + this.totalDelta.x,
198+
this.startLocation.y + this.totalDelta.y,
199+
),
200+
);
201+
return new PointerEvent(type, {
202+
clientX: blockCoords.x,
203+
clientY: blockCoords.y,
204+
});
205+
}
206+
}

0 commit comments

Comments
 (0)