Skip to content

Commit 439bd77

Browse files
committed
feat: Add a visual indicator to blocks in move mode.
1 parent da997e7 commit 439bd77

File tree

3 files changed

+264
-0
lines changed

3 files changed

+264
-0
lines changed

src/actions/mover.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as Constants from '../constants';
1717
import {Direction, getXYFromDirection} from '../drag_direction';
1818
import {KeyboardDragStrategy} from '../keyboard_drag_strategy';
1919
import {Navigation} from '../navigation';
20+
import {MoveIcon} from '../move_icon';
2021

2122
/**
2223
* The distance to move an item, in workspace coordinates, when
@@ -122,6 +123,7 @@ export class Mover {
122123
// Begin drag.
123124
dragger.onDragStart(info.fakePointerEvent('pointerdown'));
124125
info.updateTotalDelta();
126+
block.addIcon(new MoveIcon(block));
125127
return true;
126128
}
127129

@@ -142,6 +144,8 @@ export class Mover {
142144
new utils.Coordinate(0, 0),
143145
);
144146

147+
info.block.removeIcon(MoveIcon.type);
148+
145149
this.unpatchWorkspace(workspace);
146150
this.unpatchDragStrategy(info.block);
147151
this.moves.delete(workspace);
@@ -167,6 +171,8 @@ export class Mover {
167171
(info.dragger as any).shouldReturnToStart = () => true;
168172
const blockSvg = info.block;
169173

174+
blockSvg.removeIcon(MoveIcon.type);
175+
170176
// Explicitly call `hidePreview` because it is not called in revertDrag.
171177
// @ts-expect-error Access to private property dragStrategy.
172178
blockSvg.dragStrategy.connectionPreviewer.hidePreview();

src/move_icon.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly/core';
8+
import {MoveIndicatorBubble} from './move_indicator';
9+
10+
/**
11+
* Invisible icon that acts as an anchor for a move indicator bubble.
12+
*/
13+
export class MoveIcon implements Blockly.IIcon, Blockly.IHasBubble {
14+
private moveIndicator: MoveIndicatorBubble;
15+
static readonly type = new Blockly.icons.IconType('moveIndicator');
16+
17+
/**
18+
* Creates a new MoveIcon instance.
19+
*
20+
* @param sourceBlock The block this icon is attached to.
21+
*/
22+
constructor(private sourceBlock: Blockly.BlockSvg) {
23+
this.moveIndicator = new MoveIndicatorBubble(this.sourceBlock);
24+
}
25+
26+
/**
27+
* Returns the type of this icon.
28+
*/
29+
getType(): Blockly.icons.IconType<MoveIcon> {
30+
return MoveIcon.type;
31+
}
32+
33+
/**
34+
* Returns the weight of this icon, which controls its position relative to
35+
* other icons.
36+
*
37+
* @returns The weight of this icon.
38+
*/
39+
getWeight(): number {
40+
return -1;
41+
}
42+
43+
/**
44+
* Returns the size of this icon.
45+
*
46+
* @returns A rect with negative width and no height to offset the default
47+
* padding applied to icons.
48+
*/
49+
getSize(): Blockly.utils.Size {
50+
// Awful hack to cancel out the default padding added to icons.
51+
return new Blockly.utils.Size(-8, 0);
52+
}
53+
54+
/**
55+
* Returns whether this icon is visible when its parent block is collapsed.
56+
*
57+
* @returns False since this icon is never visible.
58+
*/
59+
isShownWhenCollapsed(): boolean {
60+
return false;
61+
}
62+
63+
/**
64+
* Returns whether this icon can be clicked in the flyout.
65+
*
66+
* @returns False since this icon is invisible and not clickable.
67+
*/
68+
isClickableInFlyout(): boolean {
69+
return false;
70+
}
71+
72+
/**
73+
* Returns whether this icon's attached bubble is visible.
74+
*
75+
* @returns True because this icon only exists to host its bubble.
76+
*/
77+
bubbleIsVisible(): boolean {
78+
return true;
79+
}
80+
81+
/**
82+
* Called when the location of this icon's block changes.
83+
*
84+
* @param blockOrigin The new location of this icon's block.
85+
*/
86+
onLocationChange(blockOrigin: Blockly.utils.Coordinate) {
87+
this.moveIndicator?.updateLocation();
88+
}
89+
90+
/**
91+
* Disposes of this icon.
92+
*/
93+
dispose() {
94+
this.moveIndicator?.dispose();
95+
}
96+
97+
// These methods are required by the interfaces, but intentionally have no
98+
// implementation, largely because this icon has no visual representation.
99+
applyColour() {}
100+
101+
hideForInsertionMarker() {}
102+
103+
updateEditable() {}
104+
105+
updateCollapsed() {}
106+
107+
setOffsetInBlock() {}
108+
109+
onClick() {}
110+
111+
async setBubbleVisible(visible: boolean) {}
112+
113+
initView(pointerDownListener: (e: PointerEvent) => void) {}
114+
}

src/move_indicator.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly/core';
8+
9+
/**
10+
* Bubble that displays a four-way arrow attached to a block to indicate that
11+
* it is in move mode.
12+
*/
13+
export class MoveIndicatorBubble
14+
implements Blockly.IBubble, Blockly.IRenderedElement
15+
{
16+
/**
17+
* Root SVG element for this bubble.
18+
*/
19+
svgRoot: SVGGElement;
20+
21+
/**
22+
* The location of this bubble in workspace coordinates.
23+
*/
24+
location = new Blockly.utils.Coordinate(0, 0);
25+
26+
/**
27+
* Creates a new move indicator bubble.
28+
*
29+
* @param sourceBlock The block this bubble should be associated with.
30+
*/
31+
constructor(private sourceBlock: Blockly.BlockSvg) {
32+
this.svgRoot = Blockly.utils.dom.createSvgElement(
33+
Blockly.utils.Svg.G,
34+
{},
35+
this.sourceBlock.workspace.getBubbleCanvas(),
36+
);
37+
Blockly.utils.dom.createSvgElement(
38+
Blockly.utils.Svg.CIRCLE,
39+
{
40+
'fill': 'white',
41+
'fill-opacity': '0.8',
42+
'stroke': 'grey',
43+
'stroke-width': '1',
44+
'r': 20,
45+
'cx': 20,
46+
'cy': 20,
47+
},
48+
this.svgRoot,
49+
);
50+
Blockly.utils.dom.createSvgElement(
51+
Blockly.utils.Svg.PATH,
52+
{
53+
'fill': 'none',
54+
'stroke': 'currentColor',
55+
'stroke-linecap': 'round',
56+
'stroke-linejoin': 'round',
57+
'stroke-width': '2',
58+
'd': 'm18 9l3 3l-3 3m-3-3h6M6 9l-3 3l3 3m-3-3h6m0 6l3 3l3-3m-3-3v6m3-15l-3-3l-3 3m3-3v6',
59+
'transform': 'translate(8 8)',
60+
},
61+
this.svgRoot,
62+
);
63+
64+
this.updateLocation();
65+
}
66+
67+
/**
68+
* Returns whether this bubble is movable by the user.
69+
*
70+
* @returns Always returns false.
71+
*/
72+
isMovable(): boolean {
73+
return false;
74+
}
75+
76+
/**
77+
* Returns the root SVG element for this bubble.
78+
*
79+
* @returns The root SVG element.
80+
*/
81+
getSvgRoot(): SVGGElement {
82+
return this.svgRoot;
83+
}
84+
85+
/**
86+
* Recalculates this bubble's location, keeping it adjacent to its block.
87+
*/
88+
updateLocation() {
89+
const bounds = this.sourceBlock.getBoundingRectangle();
90+
const x = this.sourceBlock.workspace.RTL
91+
? bounds.left + 20
92+
: bounds.right - 20;
93+
const y = bounds.top - 20;
94+
this.moveTo(x, y);
95+
this.sourceBlock.workspace.getLayerManager()?.moveToDragLayer(this);
96+
}
97+
98+
/**
99+
* Moves this bubble to the specified location.
100+
*
101+
* @param x The location on the X axis to move to.
102+
* @param y The location on the Y axis to move to.
103+
*/
104+
moveTo(x: number, y: number) {
105+
this.location.x = x;
106+
this.location.y = y;
107+
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
108+
}
109+
110+
/**
111+
* Returns this bubble's location in workspace coordinates.
112+
*
113+
* @returns The bubble's location.
114+
*/
115+
getRelativeToSurfaceXY(): Blockly.utils.Coordinate {
116+
return this.location;
117+
}
118+
119+
/**
120+
* Disposes of this move indicator bubble.
121+
*/
122+
dispose() {
123+
Blockly.utils.dom.removeNode(this.svgRoot);
124+
}
125+
126+
// These methods are required by the interfaces, but intentionally have no
127+
// implementation, largely because this bubble's location is fixed relative
128+
// to its block and is not draggable by the user.
129+
showContextMenu() {}
130+
131+
setDragging(dragging: boolean) {}
132+
133+
startDrag(event: PointerEvent) {}
134+
135+
drag(newLocation: Blockly.utils.Coordinate, event: PointerEvent) {}
136+
137+
moveDuringDrag(newLocation: Blockly.utils.Coordinate) {}
138+
139+
endDrag() {}
140+
141+
revertDrag() {}
142+
143+
setDeleteStyle(enable: boolean) {}
144+
}

0 commit comments

Comments
 (0)