Skip to content

Commit 1448451

Browse files
authored
feat: Add a visual indicator to blocks in move mode. (#472)
* feat: Add a visual indicator to blocks in move mode. * fix: Fix positioning of move indicator in RTL mode. * chore: Make the linter ignore svg attribute names. * chore: Remove errant RTL flag. * refactor: Move drag indicator addition/removal into KeyboardDragStrategy.
1 parent ae9498f commit 1448451

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed

src/keyboard_drag_strategy.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from 'blockly';
1616
import {Direction, getDirectionFromXY} from './drag_direction';
1717
import {showUnconstrainedMoveHint} from './hints';
18+
import {MoveIcon} from './move_icon';
1819

1920
// Copied in from core because it is not exported.
2021
interface ConnectionCandidate {
@@ -60,6 +61,8 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy {
6061
// @ts-expect-error connectionCandidate is private.
6162
this.connectionCandidate = this.createInitialCandidate();
6263
this.forceShowPreview();
64+
// @ts-expect-error block is private.
65+
this.block.addIcon(new MoveIcon(this.block));
6366
}
6467

6568
override drag(newLoc: utils.Coordinate, e?: PointerEvent): void {
@@ -95,6 +98,12 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy {
9598
}
9699
}
97100

101+
override endDrag(e?: PointerEvent) {
102+
super.endDrag(e);
103+
// @ts-expect-error block is private.
104+
this.block.removeIcon(MoveIcon.type);
105+
}
106+
98107
/**
99108
* Returns the next compatible connection in keyboard navigation order,
100109
* based on the input direction.

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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
/* eslint-disable @typescript-eslint/naming-convention */
32+
constructor(private sourceBlock: Blockly.BlockSvg) {
33+
this.svgRoot = Blockly.utils.dom.createSvgElement(
34+
Blockly.utils.Svg.G,
35+
{},
36+
this.sourceBlock.workspace.getBubbleCanvas(),
37+
);
38+
const rtl = this.sourceBlock.workspace.RTL;
39+
Blockly.utils.dom.createSvgElement(
40+
Blockly.utils.Svg.CIRCLE,
41+
{
42+
'fill': 'white',
43+
'fill-opacity': '0.8',
44+
'stroke': 'grey',
45+
'stroke-width': '1',
46+
'r': 20,
47+
'cx': 20 * (rtl ? -1 : 1),
48+
'cy': 20,
49+
},
50+
this.svgRoot,
51+
);
52+
Blockly.utils.dom.createSvgElement(
53+
Blockly.utils.Svg.PATH,
54+
{
55+
'fill': 'none',
56+
'stroke': 'currentColor',
57+
'stroke-linecap': 'round',
58+
'stroke-linejoin': 'round',
59+
'stroke-width': '2',
60+
'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',
61+
'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`,
62+
},
63+
this.svgRoot,
64+
);
65+
66+
this.updateLocation();
67+
}
68+
69+
/**
70+
* Returns whether this bubble is movable by the user.
71+
*
72+
* @returns Always returns false.
73+
*/
74+
isMovable(): boolean {
75+
return false;
76+
}
77+
78+
/**
79+
* Returns the root SVG element for this bubble.
80+
*
81+
* @returns The root SVG element.
82+
*/
83+
getSvgRoot(): SVGGElement {
84+
return this.svgRoot;
85+
}
86+
87+
/**
88+
* Recalculates this bubble's location, keeping it adjacent to its block.
89+
*/
90+
updateLocation() {
91+
const bounds = this.sourceBlock.getBoundingRectangle();
92+
const x = this.sourceBlock.workspace.RTL
93+
? bounds.left + 20
94+
: bounds.right - 20;
95+
const y = bounds.top - 20;
96+
this.moveTo(x, y);
97+
this.sourceBlock.workspace.getLayerManager()?.moveToDragLayer(this);
98+
}
99+
100+
/**
101+
* Moves this bubble to the specified location.
102+
*
103+
* @param x The location on the X axis to move to.
104+
* @param y The location on the Y axis to move to.
105+
*/
106+
moveTo(x: number, y: number) {
107+
this.location.x = x;
108+
this.location.y = y;
109+
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
110+
}
111+
112+
/**
113+
* Returns this bubble's location in workspace coordinates.
114+
*
115+
* @returns The bubble's location.
116+
*/
117+
getRelativeToSurfaceXY(): Blockly.utils.Coordinate {
118+
return this.location;
119+
}
120+
121+
/**
122+
* Disposes of this move indicator bubble.
123+
*/
124+
dispose() {
125+
Blockly.utils.dom.removeNode(this.svgRoot);
126+
}
127+
128+
// These methods are required by the interfaces, but intentionally have no
129+
// implementation, largely because this bubble's location is fixed relative
130+
// to its block and is not draggable by the user.
131+
showContextMenu() {}
132+
133+
setDragging(dragging: boolean) {}
134+
135+
startDrag(event: PointerEvent) {}
136+
137+
drag(newLocation: Blockly.utils.Coordinate, event: PointerEvent) {}
138+
139+
moveDuringDrag(newLocation: Blockly.utils.Coordinate) {}
140+
141+
endDrag() {}
142+
143+
revertDrag() {}
144+
145+
setDeleteStyle(enable: boolean) {}
146+
}

0 commit comments

Comments
 (0)