Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/actions/mover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as Constants from '../constants';
import {Direction, getXYFromDirection} from '../drag_direction';
import {KeyboardDragStrategy} from '../keyboard_drag_strategy';
import {Navigation} from '../navigation';
import {MoveIcon} from '../move_icon';
import {clearMoveHints} from '../hints';

/**
Expand Down Expand Up @@ -123,6 +124,7 @@ export class Mover {
// Begin drag.
dragger.onDragStart(info.fakePointerEvent('pointerdown'));
info.updateTotalDelta();
block.addIcon(new MoveIcon(block));
return true;
}

Expand All @@ -145,6 +147,8 @@ export class Mover {
new utils.Coordinate(0, 0),
);

info.block.removeIcon(MoveIcon.type);

this.unpatchWorkspace(workspace);
this.unpatchDragStrategy(info.block);
this.moves.delete(workspace);
Expand Down Expand Up @@ -172,6 +176,8 @@ export class Mover {
(info.dragger as any).shouldReturnToStart = () => true;
const blockSvg = info.block;

blockSvg.removeIcon(MoveIcon.type);

// Explicitly call `hidePreview` because it is not called in revertDrag.
// @ts-expect-error Access to private property dragStrategy.
blockSvg.dragStrategy.connectionPreviewer.hidePreview();
Expand Down
114 changes: 114 additions & 0 deletions src/move_icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';
import {MoveIndicatorBubble} from './move_indicator';

/**
* Invisible icon that acts as an anchor for a move indicator bubble.
*/
export class MoveIcon implements Blockly.IIcon, Blockly.IHasBubble {
private moveIndicator: MoveIndicatorBubble;
static readonly type = new Blockly.icons.IconType('moveIndicator');

/**
* Creates a new MoveIcon instance.
*
* @param sourceBlock The block this icon is attached to.
*/
constructor(private sourceBlock: Blockly.BlockSvg) {
this.moveIndicator = new MoveIndicatorBubble(this.sourceBlock);
}

/**
* Returns the type of this icon.
*/
getType(): Blockly.icons.IconType<MoveIcon> {
return MoveIcon.type;
}

/**
* Returns the weight of this icon, which controls its position relative to
* other icons.
*
* @returns The weight of this icon.
*/
getWeight(): number {
return -1;
}

/**
* Returns the size of this icon.
*
* @returns A rect with negative width and no height to offset the default
* padding applied to icons.
*/
getSize(): Blockly.utils.Size {
// Awful hack to cancel out the default padding added to icons.
return new Blockly.utils.Size(-8, 0);
}

/**
* Returns whether this icon is visible when its parent block is collapsed.
*
* @returns False since this icon is never visible.
*/
isShownWhenCollapsed(): boolean {
return false;
}

/**
* Returns whether this icon can be clicked in the flyout.
*
* @returns False since this icon is invisible and not clickable.
*/
isClickableInFlyout(): boolean {
return false;
}

/**
* Returns whether this icon's attached bubble is visible.
*
* @returns True because this icon only exists to host its bubble.
*/
bubbleIsVisible(): boolean {
return true;
}

/**
* Called when the location of this icon's block changes.
*
* @param blockOrigin The new location of this icon's block.
*/
onLocationChange(blockOrigin: Blockly.utils.Coordinate) {
this.moveIndicator?.updateLocation();
}

/**
* Disposes of this icon.
*/
dispose() {
this.moveIndicator?.dispose();
}

// These methods are required by the interfaces, but intentionally have no
// implementation, largely because this icon has no visual representation.
applyColour() {}

hideForInsertionMarker() {}

updateEditable() {}

updateCollapsed() {}

setOffsetInBlock() {}

onClick() {}

async setBubbleVisible(visible: boolean) {}

initView(pointerDownListener: (e: PointerEvent) => void) {}
}
146 changes: 146 additions & 0 deletions src/move_indicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';

/**
* Bubble that displays a four-way arrow attached to a block to indicate that
* it is in move mode.
*/
export class MoveIndicatorBubble
implements Blockly.IBubble, Blockly.IRenderedElement
{
/**
* Root SVG element for this bubble.
*/
svgRoot: SVGGElement;

/**
* The location of this bubble in workspace coordinates.
*/
location = new Blockly.utils.Coordinate(0, 0);

/**
* Creates a new move indicator bubble.
*
* @param sourceBlock The block this bubble should be associated with.
*/
/* eslint-disable @typescript-eslint/naming-convention */
constructor(private sourceBlock: Blockly.BlockSvg) {
this.svgRoot = Blockly.utils.dom.createSvgElement(
Blockly.utils.Svg.G,
{},
this.sourceBlock.workspace.getBubbleCanvas(),
);
const rtl = this.sourceBlock.workspace.RTL;
Blockly.utils.dom.createSvgElement(
Blockly.utils.Svg.CIRCLE,
{
'fill': 'white',
'fill-opacity': '0.8',
'stroke': 'grey',
'stroke-width': '1',
'r': 20,
'cx': 20 * (rtl ? -1 : 1),
'cy': 20,
},
this.svgRoot,
);
Blockly.utils.dom.createSvgElement(
Blockly.utils.Svg.PATH,
{
'fill': 'none',
'stroke': 'currentColor',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
'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',
'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`,
},
this.svgRoot,
);

this.updateLocation();
}

/**
* Returns whether this bubble is movable by the user.
*
* @returns Always returns false.
*/
isMovable(): boolean {
return false;
}

/**
* Returns the root SVG element for this bubble.
*
* @returns The root SVG element.
*/
getSvgRoot(): SVGGElement {
return this.svgRoot;
}

/**
* Recalculates this bubble's location, keeping it adjacent to its block.
*/
updateLocation() {
const bounds = this.sourceBlock.getBoundingRectangle();
const x = this.sourceBlock.workspace.RTL
? bounds.left + 20
: bounds.right - 20;
const y = bounds.top - 20;
this.moveTo(x, y);
this.sourceBlock.workspace.getLayerManager()?.moveToDragLayer(this);
}

/**
* Moves this bubble to the specified location.
*
* @param x The location on the X axis to move to.
* @param y The location on the Y axis to move to.
*/
moveTo(x: number, y: number) {
this.location.x = x;
this.location.y = y;
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
}

/**
* Returns this bubble's location in workspace coordinates.
*
* @returns The bubble's location.
*/
getRelativeToSurfaceXY(): Blockly.utils.Coordinate {
return this.location;
}

/**
* Disposes of this move indicator bubble.
*/
dispose() {
Blockly.utils.dom.removeNode(this.svgRoot);
}

// These methods are required by the interfaces, but intentionally have no
// implementation, largely because this bubble's location is fixed relative
// to its block and is not draggable by the user.
showContextMenu() {}

setDragging(dragging: boolean) {}

startDrag(event: PointerEvent) {}

drag(newLocation: Blockly.utils.Coordinate, event: PointerEvent) {}

moveDuringDrag(newLocation: Blockly.utils.Coordinate) {}

endDrag() {}

revertDrag() {}

setDeleteStyle(enable: boolean) {}
}
1 change: 1 addition & 0 deletions test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import {load} from './loadTestBlocks';
import {runCode, registerRunCodeShortcut} from './runCode';

(window as any).Blockly = Blockly;

Check warning on line 26 in test/index.ts

View workflow job for this annotation

GitHub Actions / Eslint check

Unexpected any. Specify a different type

/**
* Parse query params for inject and navigation options and update
Expand Down Expand Up @@ -90,6 +90,7 @@
const injectOptions = {
toolbox,
renderer,
rtl: true,
};
const blocklyDiv = document.getElementById('blocklyDiv');
if (!blocklyDiv) {
Expand Down
Loading