Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions src/keyboard_drag_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from 'blockly';
import {Direction, getDirectionFromXY} from './drag_direction';
import {showUnconstrainedMoveHint} from './hints';
import {MoveIcon} from './move_icon';

// Copied in from core because it is not exported.
interface ConnectionCandidate {
Expand Down Expand Up @@ -44,6 +45,8 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy {
// @ts-expect-error connectionCandidate is private.
this.connectionCandidate = this.createInitialCandidate();
this.forceShowPreview();
// @ts-expect-error block is private.
this.block.addIcon(new MoveIcon(this.block));
}

override drag(newLoc: utils.Coordinate, e?: PointerEvent): void {
Expand Down Expand Up @@ -80,6 +83,12 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy {
}
}

override endDrag(e?: PointerEvent) {
super.endDrag(e);
// @ts-expect-error block is private.
this.block.removeIcon(MoveIcon.type);
}

/**
* Returns the next compatible connection in keyboard navigation order,
* based on the input direction.
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) {}
}
Loading