From c44f711f86320e57144c366a68382b9c96be9871 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Feb 2026 11:20:58 -0800 Subject: [PATCH 01/23] refactor!: Update dragging APIs. --- packages/blockly/core/block_svg.ts | 19 +++-- packages/blockly/core/blockly.ts | 2 + packages/blockly/core/bubbles/bubble.ts | 5 +- .../comments/rendered_workspace_comment.ts | 4 +- .../core/dragging/block_drag_strategy.ts | 31 +++++--- .../core/dragging/bubble_drag_strategy.ts | 11 ++- .../core/dragging/comment_drag_strategy.ts | 13 ++-- packages/blockly/core/dragging/dragger.ts | 73 +++++++++++++------ .../blockly/core/interfaces/i_draggable.ts | 38 ++++++---- packages/blockly/core/interfaces/i_dragger.ts | 38 +++++++--- packages/blockly/core/workspace_svg.ts | 6 +- 11 files changed, 158 insertions(+), 82 deletions(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 83af5188e99..74f8eb79d9e 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -43,7 +43,11 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {ICopyable} from './interfaces/i_copyable.js'; import {IDeletable} from './interfaces/i_deletable.js'; -import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js'; +import type { + DragDisposition, + IDragStrategy, + IDraggable, +} from './interfaces/i_draggable.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {IIcon} from './interfaces/i_icon.js'; @@ -1784,18 +1788,21 @@ export class BlockSvg } /** Starts a drag on the block. */ - startDrag(e?: PointerEvent): void { - this.dragStrategy.startDrag(e); + startDrag(e?: PointerEvent | KeyboardEvent): IDraggable { + return this.dragStrategy.startDrag(e); } /** Drags the block to the given location. */ - drag(newLoc: Coordinate, e?: PointerEvent): void { + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void { this.dragStrategy.drag(newLoc, e); } /** Ends the drag on the block. */ - endDrag(e?: PointerEvent): void { - this.dragStrategy.endDrag(e); + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void { + this.dragStrategy.endDrag(e, disposition); } /** Moves the block back to where it was at the start of a drag. */ diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 99112d790fb..274dd7f5881 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -137,6 +137,7 @@ import {IDeletable, isDeletable} from './interfaces/i_deletable.js'; import {IDeleteArea} from './interfaces/i_delete_area.js'; import {IDragTarget} from './interfaces/i_drag_target.js'; import { + DragDisposition, IDragStrategy, IDraggable, isDraggable, @@ -500,6 +501,7 @@ export { BlockFlyoutInflater, ButtonFlyoutInflater, CodeGenerator, + DragDisposition, Field, FieldCheckbox, FieldCheckboxConfig, diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 742d300adf1..4f83542ea3c 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -9,6 +9,7 @@ import * as common from '../common.js'; import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; import {getFocusManager} from '../focus_manager.js'; import {IBubble} from '../interfaces/i_bubble.js'; +import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; @@ -664,8 +665,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { } /** Starts a drag on the bubble. */ - startDrag(): void { - this.dragStrategy.startDrag(); + startDrag(): IDraggable { + return this.dragStrategy.startDrag(); } /** Drags the bubble to the given location. */ diff --git a/packages/blockly/core/comments/rendered_workspace_comment.ts b/packages/blockly/core/comments/rendered_workspace_comment.ts index 59e462c9507..87ec5a2cb5c 100644 --- a/packages/blockly/core/comments/rendered_workspace_comment.ts +++ b/packages/blockly/core/comments/rendered_workspace_comment.ts @@ -239,8 +239,8 @@ export class RenderedWorkspaceComment } /** Starts a drag on the comment. */ - startDrag(): void { - this.dragStrategy.startDrag(); + startDrag(): IDraggable { + return this.dragStrategy.startDrag(); } /** Drags the comment to the given location. */ diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 0fb6d531eea..b644fffe2ab 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -6,7 +6,7 @@ import type {Block} from '../block.js'; import * as blockAnimation from '../block_animations.js'; -import {BlockSvg} from '../block_svg.js'; +import type {BlockSvg} from '../block_svg.js'; import * as bumpObjects from '../bump_objects.js'; import {config} from '../config.js'; import {Connection} from '../connection.js'; @@ -15,16 +15,20 @@ import type {BlockMove} from '../events/events_block_move.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IBubble} from '../interfaces/i_bubble.js'; -import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; +import type { + DragDisposition, + IDragStrategy, + IDraggable, +} from '../interfaces/i_draggable.js'; import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js'; import * as layers from '../layers.js'; import * as registry from '../registry.js'; import {finishQueuedRenders} from '../render_management.js'; -import {RenderedConnection} from '../rendered_connection.js'; +import type {RenderedConnection} from '../rendered_connection.js'; import {Coordinate} from '../utils.js'; import * as dom from '../utils/dom.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; /** Represents a nearby valid connection. */ interface ConnectionCandidate { @@ -91,10 +95,10 @@ export class BlockDragStrategy implements IDragStrategy { * Handles any setup for starting the drag, including disconnecting the block * from any parent blocks. */ - startDrag(e?: PointerEvent): void { + startDrag(e?: PointerEvent | KeyboardEvent): IDraggable { if (this.block.isShadow()) { this.startDraggingShadow(e); - return; + return this.block.getParent()!; } this.dragging = true; @@ -125,6 +129,8 @@ export class BlockDragStrategy implements IDragStrategy { this.getVisibleBubbles(this.block).forEach((bubble) => { this.workspace.getLayerManager()?.moveToDragLayer(bubble, false); }); + + return this.block; } /** @@ -159,12 +165,12 @@ export class BlockDragStrategy implements IDragStrategy { * @returns True if just the initial block should be dragged out, false * if all following blocks should also be dragged. */ - protected shouldHealStack(e: PointerEvent | undefined) { + protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) { return !!e && (e.altKey || e.ctrlKey || e.metaKey); } /** Starts a drag on a shadow, recording the drag offset. */ - private startDraggingShadow(e?: PointerEvent) { + private startDraggingShadow(e?: PointerEvent | KeyboardEvent) { const parent = this.block.getParent(); if (!parent) { throw new Error( @@ -402,9 +408,12 @@ export class BlockDragStrategy implements IDragStrategy { * Cleans up any state at the end of the drag. Applies any pending * connections. */ - endDrag(e?: PointerEvent): void { + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void { if (this.block.isShadow()) { - this.block.getParent()?.endDrag(e); + this.block.getParent()?.endDrag(e, disposition); return; } this.originalEventGroup = eventUtils.getGroup(); diff --git a/packages/blockly/core/dragging/bubble_drag_strategy.ts b/packages/blockly/core/dragging/bubble_drag_strategy.ts index 8a5a6783910..bf415ab8279 100644 --- a/packages/blockly/core/dragging/bubble_drag_strategy.ts +++ b/packages/blockly/core/dragging/bubble_drag_strategy.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {IBubble, WorkspaceSvg} from '../blockly.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; +import type {IDragStrategy, IDraggable} from '../interfaces/i_draggable.js'; import * as layers from '../layers.js'; -import {Coordinate} from '../utils.js'; +import type {Coordinate} from '../utils.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class BubbleDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; @@ -21,13 +22,15 @@ export class BubbleDragStrategy implements IDragStrategy { return true; } - startDrag(): void { + startDrag(): IDraggable { this.startLoc = this.bubble.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.bubble); if (this.bubble.setDragging) { this.bubble.setDragging(true); } + + return this.bubble; } drag(newLoc: Coordinate): void { diff --git a/packages/blockly/core/dragging/comment_drag_strategy.ts b/packages/blockly/core/dragging/comment_drag_strategy.ts index b7974d8b4ca..1a05bc1e09e 100644 --- a/packages/blockly/core/dragging/comment_drag_strategy.ts +++ b/packages/blockly/core/dragging/comment_drag_strategy.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {RenderedWorkspaceComment} from '../comments.js'; -import {CommentMove} from '../events/events_comment_move.js'; +import type {RenderedWorkspaceComment} from '../comments.js'; +import type {CommentMove} from '../events/events_comment_move.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IDraggable, IDragStrategy} from '../interfaces/i_draggable.js'; import * as layers from '../layers.js'; -import {Coordinate} from '../utils.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {Coordinate} from '../utils.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class CommentDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; @@ -30,12 +30,13 @@ export class CommentDragStrategy implements IDragStrategy { ); } - startDrag(): void { + startDrag(): IDraggable { this.fireDragStartEvent(); this.startLoc = this.comment.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.comment); this.comment.setDragging(true); + return this.comment; } drag(newLoc: Coordinate): void { diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index 02e9e2bfb79..0901919c146 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -9,15 +9,16 @@ import {BlockSvg} from '../block_svg.js'; import {ComponentManager} from '../component_manager.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; -import {IDeletable, isDeletable} from '../interfaces/i_deletable.js'; -import {IDeleteArea} from '../interfaces/i_delete_area.js'; -import {IDragTarget} from '../interfaces/i_drag_target.js'; -import {IDraggable} from '../interfaces/i_draggable.js'; -import {IDragger} from '../interfaces/i_dragger.js'; +import type {IDeletable} from '../interfaces/i_deletable.js'; +import {isDeletable} from '../interfaces/i_deletable.js'; +import type {IDeleteArea} from '../interfaces/i_delete_area.js'; +import type {IDragTarget} from '../interfaces/i_drag_target.js'; +import {DragDisposition, type IDraggable} from '../interfaces/i_draggable.js'; +import type {IDragger} from '../interfaces/i_dragger.js'; import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {Coordinate} from '../utils/coordinate.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class Dragger implements IDragger { protected startLoc: Coordinate; @@ -32,7 +33,7 @@ export class Dragger implements IDragger { } /** Handles any drag startup. */ - onDragStart(e: PointerEvent) { + onDragStart(e?: PointerEvent | KeyboardEvent) { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } @@ -45,21 +46,26 @@ export class Dragger implements IDragger { * @param totalDelta The total amount in pixel coordinates the mouse has moved * since the start of the drag. */ - onDrag(e: PointerEvent, totalDelta: Coordinate) { + onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) { this.moveDraggable(e, totalDelta); const root = this.getRoot(this.draggable); // Must check `wouldDelete` before calling other hooks on drag targets // since we have documented that we would do so. if (isDeletable(root)) { - root.setDeleteStyle(this.wouldDeleteDraggable(e, root)); + root.setDeleteStyle( + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + root, + ), + ); } - this.updateDragTarget(e); + this.updateDragTarget(this.draggable.getRelativeToSurfaceXY()); } /** Updates the drag target under the pointer (if there is one). */ - protected updateDragTarget(e: PointerEvent) { - const newDragTarget = this.workspace.getDragTarget(e); + protected updateDragTarget(coordinate: Coordinate) { + const newDragTarget = this.workspace.getDragTarget(coordinate); const root = this.getRoot(this.draggable); if (this.dragTarget !== newDragTarget) { this.dragTarget?.onDragExit(root); @@ -73,7 +79,10 @@ export class Dragger implements IDragger { * Calculates the correct workspace coordinate for the movable and tells * the draggable to go to that location. */ - private moveDraggable(e: PointerEvent, totalDelta: Coordinate) { + private moveDraggable( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ) { const delta = this.pixelsToWorkspaceUnits(totalDelta); const newLoc = Coordinate.sum(this.startLoc, delta); this.draggable.drag(newLoc, e); @@ -84,10 +93,10 @@ export class Dragger implements IDragger { * at the current location. */ protected wouldDeleteDraggable( - e: PointerEvent, + coordinate: Coordinate, rootDraggable: IDraggable & IDeletable, ) { - const dragTarget = this.workspace.getDragTarget(e); + const dragTarget = this.workspace.getDragTarget(coordinate); if (!dragTarget) return false; const componentManager = this.workspace.getComponentManager(); @@ -101,34 +110,41 @@ export class Dragger implements IDragger { } /** Handles any drag cleanup. */ - onDragEnd(e: PointerEvent) { + onDragEnd(e?: PointerEvent | KeyboardEvent) { const origGroup = eventUtils.getGroup(); - const dragTarget = this.workspace.getDragTarget(e); + const dragTarget = this.workspace.getDragTarget( + this.draggable.getRelativeToSurfaceXY(), + ); const root = this.getRoot(this.draggable); if (dragTarget) { this.dragTarget?.onDrop(root); } - if (this.shouldReturnToStart(e, root)) { + if ( + this.shouldReturnToStart(this.draggable.getRelativeToSurfaceXY(), root) + ) { this.draggable.revertDrag(); } - const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root); + const wouldDelete = + isDeletable(root) && + this.wouldDeleteDraggable(this.draggable.getRelativeToSurfaceXY(), root); // TODO(#8148): use a generalized API instead of an instanceof check. if (wouldDelete && this.draggable instanceof BlockSvg) { blockAnimations.disposeUiEffect(this.draggable.getRootBlock()); } - this.draggable.endDrag(e); - if (wouldDelete && isDeletable(root)) { + this.draggable.endDrag(e, DragDisposition.DELETE); // We want to make sure the delete gets grouped with any possible move // event. In core Blockly this shouldn't happen, but due to a change // in behavior older custom draggables might still clear the group. eventUtils.setGroup(origGroup); root.dispose(); + } else { + this.draggable.endDrag(e, DragDisposition.COMMIT); } eventUtils.setGroup(false); @@ -139,6 +155,14 @@ export class Dragger implements IDragger { } } + /** Handles a drag being reverted. */ + onDragRevert() { + this.draggable.revertDrag(); + if (isFocusableNode(this.draggable)) { + getFocusManager().focusNode(this.draggable); + } + } + // We need to special case blocks for now so that we look at the root block // instead of the one actually being dragged in most cases. private getRoot(draggable: IDraggable): IDraggable { @@ -149,8 +173,11 @@ export class Dragger implements IDragger { * Returns true if we should return the draggable to its original location * at the end of the drag. */ - protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) { - const dragTarget = this.workspace.getDragTarget(e); + protected shouldReturnToStart( + coordinate: Coordinate, + rootDraggable: IDraggable, + ) { + const dragTarget = this.workspace.getDragTarget(coordinate); if (!dragTarget) return false; return dragTarget.shouldPreventMove(rootDraggable); } diff --git a/packages/blockly/core/interfaces/i_draggable.ts b/packages/blockly/core/interfaces/i_draggable.ts index 9130381163f..f9bdf125a48 100644 --- a/packages/blockly/core/interfaces/i_draggable.ts +++ b/packages/blockly/core/interfaces/i_draggable.ts @@ -4,15 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {Coordinate} from '../utils/coordinate'; +import type {Coordinate} from '../utils/coordinate'; + +export enum DragDisposition { + COMMIT = 1, + DELETE = 2, +} /** * Represents an object that can be dragged. */ export interface IDraggable extends IDragStrategy { /** - * Returns the current location of the draggable in workspace - * coordinates. + * Returns the current location of the draggable in workspace coordinates. * * @returns Coordinate of current location on workspace. */ @@ -27,11 +31,11 @@ export interface IDragStrategy { * Handles any drag startup (e.g moving elements to the front of the * workspace). * - * @param e PointerEvent that started the drag; can be used to - * check modifier keys, etc. May be missing when dragging is - * triggered programatically rather than by user. + * @param e Event that started the drag; can be used to check modifier keys, + * etc. May be missing when dragging is triggered programmatically rather + * than by user. */ - startDrag(e?: PointerEvent): void; + startDrag(e?: PointerEvent | KeyboardEvent): IDraggable; /** * Handles moving elements to the new location, and updating any @@ -39,21 +43,23 @@ export interface IDragStrategy { * * @param newLoc Workspace coordinate to which the draggable has * been dragged. - * @param e PointerEvent that continued the drag. Can be - * used to check modifier keys, etc. + * @param e Event that continued the drag. Can be used to check modifier + * keys, etc. */ - drag(newLoc: Coordinate, e?: PointerEvent): void; + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void; /** - * Handles any drag cleanup, including e.g. connecting or deleting - * blocks. + * Handles any drag cleanup, including e.g. connecting or deleting blocks. * * @param newLoc Workspace coordinate at which the drag finished. - * been dragged. - * @param e PointerEvent that finished the drag. Can be - * used to check modifier keys, etc. + * @param e Event that finished the drag. Can be used to check modifier keys, + * etc. + * @param disposition The end result of the drag. */ - endDrag(e?: PointerEvent): void; + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void; /** Moves the draggable back to where it was at the start of the drag. */ revertDrag(): void; diff --git a/packages/blockly/core/interfaces/i_dragger.ts b/packages/blockly/core/interfaces/i_dragger.ts index 1e8ad0ab6c4..12f03151068 100644 --- a/packages/blockly/core/interfaces/i_dragger.ts +++ b/packages/blockly/core/interfaces/i_dragger.ts @@ -4,32 +4,50 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {Coordinate} from '../utils/coordinate'; +import type {Coordinate} from '../utils/coordinate'; export interface IDragger { /** * Handles any drag startup. * - * @param e PointerEvent that started the drag. + * @param e Event that started the drag. */ - onDragStart(e: PointerEvent): void; + onDragStart(e?: PointerEvent | KeyboardEvent): void; /** * Handles dragging, including calculating where the element should * actually be moved to. * - * @param e PointerEvent that continued the drag. - * @param totalDelta The total distance, in pixels, that the mouse + * @param e Event that continued the drag. + * @param totalDelta The total distance, in pixels, that the draggable * has moved since the start of the drag. */ - onDrag(e: PointerEvent, totalDelta: Coordinate): void; + onDrag( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; /** - * Handles any drag cleanup. + * Handles any drag cleanup when a drag finishes normally. * - * @param e PointerEvent that finished the drag. - * @param totalDelta The total distance, in pixels, that the mouse + * @param e Event that finished the drag. + * @param totalDelta The total distance, in pixels, that the draggable * has moved since the start of the drag. */ - onDragEnd(e: PointerEvent, totalDelta: Coordinate): void; + onDragEnd( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; + + /** + * Handles any drag cleanup when a drag is reverted. + * + * @param e Event that finished the drag. + * @param totalDelta The total distance, in pixels, that the draggable + * has moved since the start of the drag. + */ + onDragRevert( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; } diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index de158c6d426..f72937d382e 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -1431,9 +1431,11 @@ export class WorkspaceSvg * @returns Null if not over a drag target, or the drag target the event is * over. */ - getDragTarget(e: PointerEvent): IDragTarget | null { + getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null { + const coordinate = + e instanceof Coordinate ? e : new Coordinate(e.clientX, e.clientY); for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) { - if (targetArea.clientRect.contains(e.clientX, e.clientY)) { + if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) { return targetArea.component; } } From 15f8b6358a53ae46ca1a2a81884b0a61acbb2dbb Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Feb 2026 11:24:47 -0800 Subject: [PATCH 02/23] fix: Fix bug that caused drags to always result in deletion --- packages/blockly/core/workspace_svg.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index f72937d382e..235d1920c1e 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -1427,13 +1427,15 @@ export class WorkspaceSvg /** * Returns the drag target the pointer event is over. * - * @param e Pointer move event. + * @param e Pointer move event or a workspace coordinate. * @returns Null if not over a drag target, or the drag target the event is * over. */ getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null { const coordinate = - e instanceof Coordinate ? e : new Coordinate(e.clientX, e.clientY); + e instanceof Coordinate + ? svgMath.wsToScreenCoordinates(this, e) + : new Coordinate(e.clientX, e.clientY); for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) { if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) { return targetArea.component; From d1812161af8fae83fb9edfbe25dd3ee433209941 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Feb 2026 13:03:00 -0800 Subject: [PATCH 03/23] refactor: Clean up block drag handling with new API --- .../core/dragging/block_drag_strategy.ts | 11 +++-- packages/blockly/core/dragging/dragger.ts | 49 +++++++------------ .../blockly/core/interfaces/i_draggable.ts | 1 + 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index b644fffe2ab..d6e07ba8ce0 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -16,8 +16,10 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IBubble} from '../interfaces/i_bubble.js'; import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; -import type { +import { DragDisposition, +} from '../interfaces/i_draggable.js'; +import type { IDragStrategy, IDraggable, } from '../interfaces/i_draggable.js'; @@ -412,10 +414,11 @@ export class BlockDragStrategy implements IDragStrategy { e: PointerEvent | KeyboardEvent | undefined, disposition: DragDisposition, ): void { - if (this.block.isShadow()) { - this.block.getParent()?.endDrag(e, disposition); - return; + + if (disposition === DragDisposition.DELETE) { + blockAnimation.disposeUiEffect(this.block); } + this.originalEventGroup = eventUtils.getGroup(); this.fireDragEndEvent(); diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index 0901919c146..d4bf09780ff 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as blockAnimations from '../block_animations.js'; -import {BlockSvg} from '../block_svg.js'; import {ComponentManager} from '../component_manager.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; @@ -37,7 +35,7 @@ export class Dragger implements IDragger { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } - this.draggable.startDrag(e); + this.draggable = this.draggable.startDrag(e); } /** @@ -48,15 +46,14 @@ export class Dragger implements IDragger { */ onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) { this.moveDraggable(e, totalDelta); - const root = this.getRoot(this.draggable); // Must check `wouldDelete` before calling other hooks on drag targets // since we have documented that we would do so. - if (isDeletable(root)) { - root.setDeleteStyle( + if (isDeletable(this.draggable)) { + this.draggable.setDeleteStyle( this.wouldDeleteDraggable( this.draggable.getRelativeToSurfaceXY(), - root, + this.draggable, ), ); } @@ -66,12 +63,11 @@ export class Dragger implements IDragger { /** Updates the drag target under the pointer (if there is one). */ protected updateDragTarget(coordinate: Coordinate) { const newDragTarget = this.workspace.getDragTarget(coordinate); - const root = this.getRoot(this.draggable); if (this.dragTarget !== newDragTarget) { - this.dragTarget?.onDragExit(root); - newDragTarget?.onDragEnter(root); + this.dragTarget?.onDragExit(this.draggable); + newDragTarget?.onDragEnter(this.draggable); } - newDragTarget?.onDragOver(root); + newDragTarget?.onDragOver(this.draggable); this.dragTarget = newDragTarget; } @@ -115,36 +111,35 @@ export class Dragger implements IDragger { const dragTarget = this.workspace.getDragTarget( this.draggable.getRelativeToSurfaceXY(), ); - const root = this.getRoot(this.draggable); if (dragTarget) { - this.dragTarget?.onDrop(root); + this.dragTarget?.onDrop(this.draggable); } + let reverted = false; if ( - this.shouldReturnToStart(this.draggable.getRelativeToSurfaceXY(), root) + this.shouldReturnToStart(this.draggable.getRelativeToSurfaceXY(), this.draggable) ) { + reverted = true; this.draggable.revertDrag(); } const wouldDelete = - isDeletable(root) && - this.wouldDeleteDraggable(this.draggable.getRelativeToSurfaceXY(), root); + isDeletable(this.draggable) && + this.wouldDeleteDraggable(this.draggable.getRelativeToSurfaceXY(), this.draggable); - // TODO(#8148): use a generalized API instead of an instanceof check. - if (wouldDelete && this.draggable instanceof BlockSvg) { - blockAnimations.disposeUiEffect(this.draggable.getRootBlock()); - } - - if (wouldDelete && isDeletable(root)) { + if (wouldDelete && isDeletable(this.draggable)) { this.draggable.endDrag(e, DragDisposition.DELETE); // We want to make sure the delete gets grouped with any possible move // event. In core Blockly this shouldn't happen, but due to a change // in behavior older custom draggables might still clear the group. eventUtils.setGroup(origGroup); - root.dispose(); + this.draggable.dispose(); } else { - this.draggable.endDrag(e, DragDisposition.COMMIT); + this.draggable.endDrag( + e, + reverted ? DragDisposition.REVERT : DragDisposition.COMMIT, + ); } eventUtils.setGroup(false); @@ -163,12 +158,6 @@ export class Dragger implements IDragger { } } - // We need to special case blocks for now so that we look at the root block - // instead of the one actually being dragged in most cases. - private getRoot(draggable: IDraggable): IDraggable { - return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable; - } - /** * Returns true if we should return the draggable to its original location * at the end of the drag. diff --git a/packages/blockly/core/interfaces/i_draggable.ts b/packages/blockly/core/interfaces/i_draggable.ts index f9bdf125a48..28b59bf2c7b 100644 --- a/packages/blockly/core/interfaces/i_draggable.ts +++ b/packages/blockly/core/interfaces/i_draggable.ts @@ -9,6 +9,7 @@ import type {Coordinate} from '../utils/coordinate'; export enum DragDisposition { COMMIT = 1, DELETE = 2, + REVERT = 3, } /** From a6e9d0a5dda158ea68a5e1cda33cbaf73b616864 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 6 Feb 2026 08:30:11 -0800 Subject: [PATCH 04/23] chore: Format files --- packages/blockly/core/dragging/block_drag_strategy.ts | 10 ++-------- packages/blockly/core/dragging/dragger.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index d6e07ba8ce0..a3c98bbf0a9 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -16,13 +16,8 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IBubble} from '../interfaces/i_bubble.js'; import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; -import { - DragDisposition, -} from '../interfaces/i_draggable.js'; -import type { - IDragStrategy, - IDraggable, -} from '../interfaces/i_draggable.js'; +import type {IDragStrategy, IDraggable} from '../interfaces/i_draggable.js'; +import {DragDisposition} from '../interfaces/i_draggable.js'; import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js'; import * as layers from '../layers.js'; import * as registry from '../registry.js'; @@ -414,7 +409,6 @@ export class BlockDragStrategy implements IDragStrategy { e: PointerEvent | KeyboardEvent | undefined, disposition: DragDisposition, ): void { - if (disposition === DragDisposition.DELETE) { blockAnimation.disposeUiEffect(this.block); } diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index d4bf09780ff..1193806b7c6 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -118,7 +118,10 @@ export class Dragger implements IDragger { let reverted = false; if ( - this.shouldReturnToStart(this.draggable.getRelativeToSurfaceXY(), this.draggable) + this.shouldReturnToStart( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ) ) { reverted = true; this.draggable.revertDrag(); @@ -126,7 +129,10 @@ export class Dragger implements IDragger { const wouldDelete = isDeletable(this.draggable) && - this.wouldDeleteDraggable(this.draggable.getRelativeToSurfaceXY(), this.draggable); + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ); if (wouldDelete && isDeletable(this.draggable)) { this.draggable.endDrag(e, DragDisposition.DELETE); From b86dee19a04f0fc0f01507c10c04e16367547e12 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 6 Feb 2026 08:32:47 -0800 Subject: [PATCH 05/23] feat: Add an `isBoundedElement` type predicate --- packages/blockly/core/blockly.ts | 6 +++++- .../blockly/core/interfaces/i_bounded_element.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 274dd7f5881..33b85923500 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -125,7 +125,10 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; import {Input} from './inputs/input.js'; import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; -import {IBoundedElement} from './interfaces/i_bounded_element.js'; +import { + IBoundedElement, + isBoundedElement, +} from './interfaces/i_bounded_element.js'; import {IBubble} from './interfaces/i_bubble.js'; import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js'; import {IComponent} from './interfaces/i_component.js'; @@ -628,6 +631,7 @@ export { icons, inject, inputs, + isBoundedElement, isCopyable, isDeletable, isDraggable, diff --git a/packages/blockly/core/interfaces/i_bounded_element.ts b/packages/blockly/core/interfaces/i_bounded_element.ts index aac26855bd6..a7d2ef0e58f 100644 --- a/packages/blockly/core/interfaces/i_bounded_element.ts +++ b/packages/blockly/core/interfaces/i_bounded_element.ts @@ -29,3 +29,16 @@ export interface IBoundedElement { */ moveBy(dx: number, dy: number, reason?: string[]): void; } + +/** + * Returns whether or not the given object conforms to IBoundedElement. + * + * @param object The object to test for conformance. + * @returns True if the object conforms to IBoundedElement, otherwise false. + */ +export function isBoundedElement(object: any): object is IBoundedElement { + return ( + typeof (object as IBoundedElement).getBoundingRectangle === 'function' && + typeof (object as IBoundedElement).moveBy === 'function' + ); +} From e74b0efd75ffb435271ddee94733841ca9ca7388 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 6 Feb 2026 09:57:58 -0800 Subject: [PATCH 06/23] feat: Make `Bubble` implement `IBoundedElement` --- packages/blockly/core/bubbles/bubble.ts | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 4f83542ea3c..24630048d02 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -8,6 +8,7 @@ import * as browserEvents from '../browser_events.js'; import * as common from '../common.js'; import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; import {getFocusManager} from '../focus_manager.js'; +import {IBoundedElement} from '../interfaces/i_bounded_element.js'; import {IBubble} from '../interfaces/i_bubble.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -30,7 +31,9 @@ import {WorkspaceSvg} from '../workspace_svg.js'; * bubble, where it has a "tail" that points to the block, and a "head" that * displays arbitrary svg elements. */ -export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { +export abstract class Bubble + implements IBubble, ISelectable, IFocusableNode, IBoundedElement +{ /** The width of the border around the bubble. */ static readonly BORDER_WIDTH = 6; @@ -275,6 +278,18 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`); } + /** + * Moves the bubble by the given amounts in the x and y directions. + * + * @param dx The distance to move along the x axis. + * @param dy The distance to move along the y axis. + * @param reason A description of why this move is happening. + */ + moveBy(dx: number, dy: number, reason?: string[]) { + const origin = this.getRelativeToSurfaceXY(); + this.moveTo(origin.x + dx, origin.y + dy); + } + /** * Positions the bubble "optimally" so that the most of it is visible and * it does not overlap the rect (if provided). @@ -618,6 +633,21 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { ); } + /** + * Returns the bounds of this bubble. + * + * @returns A bounding box for this bubble. + */ + getBoundingRectangle(): Rect { + const origin = this.getRelativeToSurfaceXY(); + return new Rect( + origin.y, + origin.y + this.size.height, + origin.x, + origin.x + this.size.width, + ); + } + /** @internal */ getSvgRoot(): SVGElement { return this.svgRoot; From b16d7de6acf92b556a4139d082d7bee05d74522b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 11 Feb 2026 12:53:49 -0800 Subject: [PATCH 07/23] fix: Fix jumping/scrolling when moving blocks --- packages/blockly/core/block_svg.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 74f8eb79d9e..69b9312de8a 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1861,9 +1861,13 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { this.select(); - this.workspace.scrollBoundsIntoView( - this.getBoundingRectangleWithoutChildren(), - ); + if (getFocusManager().getFocusedNode() !== this) { + renderManagement.finishQueuedRenders().then(() => { + this.workspace.scrollBoundsIntoView( + this.getBoundingRectangleWithoutChildren(), + ); + }); + } } /** See IFocusableNode.onNodeBlur. */ From 8b2872fb60fdd50bc098ea4e4da089760739a63f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 11 Feb 2026 15:15:02 -0800 Subject: [PATCH 08/23] feat: Add a `KeyboardMover` --- .../core/keyboard_nav/keyboard_mover.ts | 294 ++++++++++++++++++ packages/blockly/core/workspace_svg.ts | 15 + 2 files changed, 309 insertions(+) create mode 100644 packages/blockly/core/keyboard_nav/keyboard_mover.ts diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts new file mode 100644 index 00000000000..fa94eb9a3b0 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; +import type {IDraggable} from '../interfaces/i_draggable.js'; +import type {IDragger} from '../interfaces/i_dragger.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {ISelectable} from '../interfaces/i_selectable.js'; +import * as registry from '../registry.js'; +import {ShortcutRegistry} from '../shortcut_registry.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {KeyCodes} from '../utils/keycodes.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Cardinal directions in which a move can proceed. + */ +export enum Direction { + NONE = 0, + UP, + DOWN, + LEFT, + RIGHT, +} + +/** + * Identifier for a keyboard shortcut that commits the in-progress move. + */ +const COMMIT_MOVE_SHORTCUT = 'commitMove'; + +/** + * Class responsible for coordinating keyboard-driven moves with the workspace + * and dragging system. + */ +export class KeyboardMover { + /** + * Object responsible for dragging workspace elements in response to move + * commands. + */ + protected dragger?: IDragger; + + /** + * The object that is currently being moved. + */ + protected draggable?: IDraggable & + IFocusableNode & + IBoundedElement & + ISelectable; + + /** + * Workspace coordinate that the current move started from. + */ + protected startLocation?: Coordinate; + + /** + * The total distance, in workspace coordinates, that the element being moved + * has been moved since the movement process started. + */ + protected totalDelta = new Coordinate(0, 0); + + /** + * The distance to move an item in workspace coordinates. + */ + protected stepDistance = 20; + + // Set up a blur listener to end the move if the user clicks away + private readonly blurListener = () => { + this.abortMove(); + }; + + /** + * Creates a new KeyboardMover instance. + * + * @param workspace The workspace that this mover will move items on. + */ + constructor(protected workspace: WorkspaceSvg) {} + + /** + * Returns true iff the given draggable is allowed to be moved. + * + * @param draggable The draggable element to try to move. + * @returns True iff movement is allowed. + */ + canMove(draggable: IDraggable) { + return !this.workspace.isReadOnly() && draggable.isMovable(); + } + + /** + * Returns true iff this Mover is currently moving an element. + * + * @returns True iff a workspace element is being moved. + */ + isMoving() { + return !!this.draggable; + } + + /** + * Start moving the currently-focused item on workspace, if possible. + * + * @param draggable The element to start moving. + * @param event The keyboard event that triggered this move. + * @returns True iff a move has successfully begun. + */ + startMove( + draggable: IDraggable & IFocusableNode & IBoundedElement & ISelectable, + event: KeyboardEvent, + ) { + if (!this.canMove(draggable) || this.isMoving()) return false; + + const DraggerClass = registry.getClassFromOptions( + registry.Type.BLOCK_DRAGGER, + this.workspace.options, + true, + ); + if (!DraggerClass) throw new Error('no Dragger registered'); + this.draggable = draggable; + this.dragger = new DraggerClass(draggable, this.workspace); + this.startLocation = this.draggable.getRelativeToSurfaceXY(); + // Record that a move is in progress and start dragging. + this.workspace.setKeyboardMoveInProgress(true); + this.dragger.onDragStart(event); + this.updateTotalDelta(); + + this.draggable + .getFocusableElement() + .addEventListener('blur', this.blurListener); + + // Register a keyboard shortcut under the key combos of all existing + // keyboard shortcuts that commits the move before allowing the real + // shortcut to proceed. This avoids all kinds of fun brokenness when + // deleting/copying/otherwise acting on a element in move mode. + const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry()) + .flatMap((shortcut) => shortcut.keyCodes) + .filter((keyCode) => { + return ( + keyCode && + ![ + KeyCodes.RIGHT, + KeyCodes.LEFT, + KeyCodes.UP, + KeyCodes.DOWN, + KeyCodes.ENTER, + KeyCodes.ESC, + KeyCodes.M, + ].includes( + typeof keyCode === 'number' + ? keyCode + : parseInt(`${keyCode.split('+').pop()}`), + ) + ); + }) + // Convince TS there aren't undefined values. + .filter((keyCode): keyCode is string | number => !!keyCode); + + const commitMoveShortcut = { + name: COMMIT_MOVE_SHORTCUT, + preconditionFn: () => { + return this.isMoving(); + }, + callback: () => { + this.finishMove(); + return false; + }, + keyCodes: shortcutKeys, + allowCollision: true, + }; + + ShortcutRegistry.registry.register(commitMoveShortcut, true); + + this.scrollCurrentElementIntoView(); + + return true; + } + + /** + * Moves the current element in the given direction. + * + * @param direction The direction to move the currently-moving element. + * @param event The event that triggered this move, if any. + * @returns True iff this action applies and has been performed. + */ + move(direction: Direction, event?: KeyboardEvent | PointerEvent) { + switch (direction) { + case Direction.UP: + this.totalDelta.y -= this.stepDistance; + break; + case Direction.DOWN: + this.totalDelta.y += this.stepDistance; + break; + case Direction.LEFT: + this.totalDelta.x -= this.stepDistance; + break; + case Direction.RIGHT: + this.totalDelta.x += this.stepDistance; + break; + } + + this.dragger?.onDrag(event, this.totalDelta.clone()); + + this.updateTotalDelta(); + this.scrollCurrentElementIntoView(); + + return true; + } + + /** + * Finish moving the item that is currently being moved. + * + * @param event The event that triggered the end of the move, if any. + * @returns True iff move successfully finished. + */ + finishMove(event?: KeyboardEvent | PointerEvent) { + this.preDragEndCleanup(); + + this.dragger?.onDragEnd(event, this.totalDelta); + + this.postDragEndCleanup(); + return true; + } + + /** + * Abort moving the currently-focused item on workspace. + * + * @param event The event that triggered the end of the move, if any. + * @returns True iff move successfully aborted. + */ + abortMove(event?: KeyboardEvent | PointerEvent) { + this.preDragEndCleanup(); + + this.dragger?.onDragRevert(event, this.totalDelta); + + this.postDragEndCleanup(); + return true; + } + + /** + * Sets the distance by which an object will be moved. + * + * @param stepDistance The distance in workspace coordinates that each move + * should move elements on the workspace by. + */ + setMoveDistance(stepDistance: number) { + this.stepDistance = stepDistance; + } + + /** + * Common clean-up for finish/abort run before terminating the move. + */ + protected preDragEndCleanup() { + ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT); + + // Remove the blur listener before ending the drag + this.draggable + ?.getFocusableElement() + .removeEventListener('blur', this.blurListener); + } + + /** + * Common clean-up for finish/abort run after terminating the move. + */ + protected postDragEndCleanup() { + this.workspace.setKeyboardMoveInProgress(false); + + this.draggable = undefined; + this.dragger = undefined; + this.startLocation = undefined; + this.totalDelta = new Coordinate(0, 0); + } + + /** + * Scrolls the current element into view. + */ + protected scrollCurrentElementIntoView() { + if (!this.draggable) return; + const bounds = this.draggable.getBoundingRectangle(); + this.workspace.scrollBoundsIntoView(bounds); + } + + /** + * Recalculates the total movement delta from the starting location and the + * current position of the item being moved. + */ + protected updateTotalDelta() { + if (!this.draggable || !this.startLocation) return; + + this.totalDelta = new Coordinate( + this.draggable.getRelativeToSurfaceXY().x - this.startLocation.x, + this.draggable.getRelativeToSurfaceXY().y - this.startLocation.y, + ); + } +} diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 235d1920c1e..20788c4cfb2 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -59,6 +59,7 @@ import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; +import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import {LayerManager} from './layer_manager.js'; @@ -347,6 +348,12 @@ export class WorkspaceSvg */ private navigator = new Navigator(); + /** + * Object responsible for moving objects on the workspace in response to + * keyboard navigation commands. + */ + private keyboardMover = new KeyboardMover(this); + /** * @param options Dictionary of options. */ @@ -2944,6 +2951,14 @@ export class WorkspaceSvg setNavigator(newNavigator: Navigator) { this.navigator = newNavigator; } + + /** + * Returns the object responsible for coordinating keyboard-driven movement of + * blocks, comments and other items on the workspace. + */ + getKeyboardMover(): KeyboardMover { + return this.keyboardMover; + } } /** From 940b9bc379a3443c9b5744b212941c1424c5c46f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 11 Feb 2026 15:15:40 -0800 Subject: [PATCH 09/23] feat: Update the `BlockDragStrategy` to support constrained movement --- .../core/dragging/block_drag_strategy.ts | 302 +++++++++++++++--- 1 file changed, 255 insertions(+), 47 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index a3c98bbf0a9..659f8c64cbe 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -19,6 +19,7 @@ import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js import type {IDragStrategy, IDraggable} from '../interfaces/i_draggable.js'; import {DragDisposition} from '../interfaces/i_draggable.js'; import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js'; +import {Direction} from '../keyboard_nav/keyboard_mover.js'; import * as layers from '../layers.js'; import * as registry from '../registry.js'; import {finishQueuedRenders} from '../render_management.js'; @@ -39,6 +40,16 @@ interface ConnectionCandidate { distance: number; } +/** + * Represents a block movement paradigm; constrained moves only to valid + * connections, while unconstrained allows free movement to anywhere on the + * workspace. + */ +enum MoveMode { + CONSTRAINED = 1, + UNCONSTRAINED = 2, +} + export class BlockDragStrategy implements IDragStrategy { private workspace: WorkspaceSvg; @@ -59,11 +70,14 @@ export class BlockDragStrategy implements IDragStrategy { private dragging = false; - /** - * If this is a shadow block, the offset between this block and the parent - * block, to add to the drag location. In workspace units. - */ - private dragOffset = new Coordinate(0, 0); + /** Where a constrained movement should start when traversing the tree. */ + private searchNode: RenderedConnection | null = null; + + /** List of all connections available on the workspace. */ + private allConnections: RenderedConnection[] = []; + + /** The current movement mode. */ + private moveMode = MoveMode.UNCONSTRAINED; /** Used to persist an event group when snapping is done async. */ private originalEventGroup = ''; @@ -74,10 +88,6 @@ export class BlockDragStrategy implements IDragStrategy { /** Returns true if the block is currently movable. False otherwise. */ isMovable(): boolean { - if (this.block.isShadow()) { - return this.block.getParent()?.isMovable() ?? false; - } - return ( this.block.isOwnMovable() && !this.block.isDeadOrDying() && @@ -93,11 +103,6 @@ export class BlockDragStrategy implements IDragStrategy { * from any parent blocks. */ startDrag(e?: PointerEvent | KeyboardEvent): IDraggable { - if (this.block.isShadow()) { - this.startDraggingShadow(e); - return this.block.getParent()!; - } - this.dragging = true; this.fireDragStartEvent(); @@ -127,6 +132,46 @@ export class BlockDragStrategy implements IDragStrategy { this.workspace.getLayerManager()?.moveToDragLayer(bubble, false); }); + // For keyboard-driven moves, cache a list of valid connection points for + // use in constrained moved mode. + if (e instanceof KeyboardEvent) { + for (const topBlock of this.block.workspace.getTopBlocks(true)) { + this.allConnections.push( + ...topBlock + .getDescendants(true) + .filter((block: BlockSvg) => !block.isShadow()) + .flatMap((block: BlockSvg) => block.getConnections_(false)) + .sort((a: RenderedConnection, b: RenderedConnection) => { + let delta = a.y - b.y; + if (delta === 0) { + delta = a.x - b.x; + } + return delta; + }), + ); + } + + // Scooch the block to be offset from the connection preview indicator. + this.block.moveDuringDrag(this.startLoc); + this.connectionCandidate = this.createInitialCandidate(); + const neighbour = this.updateConnectionPreview( + this.block, + new Coordinate(0, 0), + ); + if (neighbour) { + let offset: Coordinate; + if (neighbour.type === ConnectionType.PREVIOUS_STATEMENT) { + const origin = this.block.getRelativeToSurfaceXY(); + offset = new Coordinate(origin.x + 10, origin.y - 10); + } else { + offset = new Coordinate(neighbour.x + 10, neighbour.y + 10); + } + this.block.moveDuringDrag( + offset + ); + } + } + return this.block; } @@ -163,23 +208,9 @@ export class BlockDragStrategy implements IDragStrategy { * if all following blocks should also be dragged. */ protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) { - return !!e && (e.altKey || e.ctrlKey || e.metaKey); - } - - /** Starts a drag on a shadow, recording the drag offset. */ - private startDraggingShadow(e?: PointerEvent | KeyboardEvent) { - const parent = this.block.getParent(); - if (!parent) { - throw new Error( - 'Tried to drag a shadow block with no parent. ' + - 'Shadow blocks should always have parents.', - ); - } - this.dragOffset = Coordinate.difference( - parent.getRelativeToSurfaceXY(), - this.block.getRelativeToSurfaceXY(), - ); - parent.startDrag(e); + return e instanceof PointerEvent + ? e.altKey || e.ctrlKey || e.metaKey + : !!this.block.previousConnection; } /** @@ -249,25 +280,56 @@ export class BlockDragStrategy implements IDragStrategy { } /** Moves the block and updates any connection previews. */ - drag(newLoc: Coordinate): void { - if (this.block.isShadow()) { - this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset)); - return; + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void { + this.moveMode = + e instanceof KeyboardEvent && !e.altKey + ? MoveMode.CONSTRAINED + : MoveMode.UNCONSTRAINED; + + if (this.moveMode === MoveMode.UNCONSTRAINED) { + this.block.moveDuringDrag(newLoc); } - - this.block.moveDuringDrag(newLoc); this.updateConnectionPreview( this.block, Coordinate.difference(newLoc, this.startLoc!), ); + + // Handle the case when an unconstrained drag found a connection candidate. + if (this.connectionCandidate) { + const neighbour = this.connectionCandidate.neighbour; + // The next constrained move will resume the search from the current + // candidate location. + this.searchNode = neighbour; + if (this.moveMode === MoveMode.CONSTRAINED) { + // Position the moving block down and slightly to the right of the + // target connection. + this.block.moveDuringDrag( + new Coordinate(neighbour.x + 10, neighbour.y + 10), + ); + } + } else { + // Handle the case when unconstrained drag was far from any candidate. + this.searchNode = null; + + // if (this.moveMode === MoveMode.CONSTRAINED) { + // showUnconstrainedMoveHint(this.workspace, true); + // } + } } /** + * Renders the connection preview indicator. + * * @param draggingBlock The block being dragged. * @param delta How far the pointer has moved from the position * at the start of the drag, in workspace units. + * @returns The neighbouring connection to which the connection preview will + * be attached. */ - private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) { + private updateConnectionPreview( + draggingBlock: BlockSvg, + delta: Coordinate, + ): RenderedConnection | undefined { const currCandidate = this.connectionCandidate; const newCandidate = this.getConnectionCandidate(draggingBlock, delta); if (!newCandidate) { @@ -302,9 +364,10 @@ export class BlockDragStrategy implements IDragStrategy { neighbour, neighbour.targetBlock()!, ); - return; + } else { + this.connectionPreviewer?.previewConnection(local, neighbour); } - this.connectionPreviewer?.previewConnection(local, neighbour); + return neighbour; } /** @@ -336,6 +399,9 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, newCandidate: ConnectionCandidate, ): boolean { + // New connection is always better during a constrained move. + if (this.moveMode === MoveMode.CONSTRAINED) return false; + const {local: currLocal, neighbour: currNeighbour} = currCandiate; const localPos = new Coordinate(currLocal.x, currLocal.y); const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y); @@ -359,9 +425,26 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, ): ConnectionCandidate | null { const localConns = this.getLocalConnections(draggingBlock); - let radius = this.getSearchRadius(); let candidate = null; + if (this.moveMode === MoveMode.CONSTRAINED) { + const direction = this.getDirectionToNewLocation( + Coordinate.sum(this.startLoc!, delta), + ); + candidate = this.findTraversalCandidate( + draggingBlock, + localConns, + direction, + ); + if (candidate) { + return candidate; + } + + delta = new Coordinate(0, 0); + } + + let radius = this.getSearchRadius(); + for (const conn of localConns) { const {connection: neighbour, radius: rad} = conn.closest(radius, delta); if (neighbour) { @@ -381,6 +464,8 @@ export class BlockDragStrategy implements IDragStrategy { * Get the radius to use when searching for a nearby valid connection. */ protected getSearchRadius() { + if (this.moveMode === MoveMode.CONSTRAINED) return Infinity; + return this.connectionCandidate ? config.connectingSnapRadius : config.snapRadius; @@ -406,7 +491,7 @@ export class BlockDragStrategy implements IDragStrategy { * connections. */ endDrag( - e: PointerEvent | KeyboardEvent | undefined, + _e: PointerEvent | KeyboardEvent | undefined, disposition: DragDisposition, ): void { if (disposition === DragDisposition.DELETE) { @@ -446,6 +531,8 @@ export class BlockDragStrategy implements IDragStrategy { } else { this.block.queueRender().then(() => this.disposeStep()); } + + this.allConnections = []; } /** Disposes of any state at the end of the drag. */ @@ -483,11 +570,6 @@ export class BlockDragStrategy implements IDragStrategy { * including reconnecting connections. */ revertDrag(): void { - if (this.block.isShadow()) { - this.block.getParent()?.revertDrag(); - return; - } - this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; @@ -526,4 +608,130 @@ export class BlockDragStrategy implements IDragStrategy { this.block.setDragging(false); this.dragging = false; } + + /** + * Get the nearest valid candidate connection in traversal order. + * + * @param draggingBlock The root block being dragged. + * @param localConns The list of connections on the dragging block(s) that are + * available to connect to. + * @param direction The cardinal direction in which the block is being moved. + * @returns A candidate connection and radius, or null if none was found. + */ + findTraversalCandidate( + draggingBlock: BlockSvg, + localConns: RenderedConnection[], + direction: Direction, + ): ConnectionCandidate | null { + const connectionChecker = draggingBlock.workspace.connectionChecker; + let candidateConnection: ConnectionCandidate | null = null; + let potential: RenderedConnection | null = this.searchNode; + + while (potential && !candidateConnection) { + const potentialIndex = this.allConnections.indexOf(potential); + if (direction === Direction.UP || direction === Direction.LEFT) { + potential = + this.allConnections[potentialIndex - 1] ?? + this.allConnections[this.allConnections.length - 1]; + } else if ( + direction === Direction.DOWN || + direction === Direction.RIGHT + ) { + potential = + this.allConnections[potentialIndex + 1] ?? this.allConnections[0]; + } + + localConns.forEach((conn: RenderedConnection) => { + if ( + potential && + connectionChecker.canConnect(conn, potential, true, Infinity) + ) { + candidateConnection = { + local: conn, + neighbour: potential, + distance: 0, + }; + } + }); + if (potential == this.searchNode) break; + } + return candidateConnection; + } + + /** + * Create a candidate representing where the block was previously connected. + * Used to render the block position after picking up the block but before + * moving during a drag. + * + * @returns A connection candidate representing where the block was at the + * start of the drag. + */ + private createInitialCandidate(): ConnectionCandidate | null { + this.searchNode = /* this.startPoint ?? */ this.startParentConn ?? this.startChildConn; + + switch (this.searchNode?.type) { + case ConnectionType.INPUT_VALUE: { + if (this.block.outputConnection) { + return { + neighbour: this.searchNode, + local: this.block.outputConnection, + distance: 0, + }; + } + break; + } + case ConnectionType.NEXT_STATEMENT: { + if (this.block.previousConnection) { + return { + neighbour: this.searchNode, + local: this.block.previousConnection, + distance: 0, + }; + } + break; + } + case ConnectionType.PREVIOUS_STATEMENT: { + if (this.block.nextConnection) { + return { + neighbour: this.searchNode, + local: this.block.nextConnection, + distance: 0, + }; + } + break; + } + } + + return null; + } + + /** + * Returns the cardinal direction that the block being dragged would have to + * move in to reach the given location. + * The given coordinate should differ from the current location on only one + * axis. + * + * @param newLocation The intended destination for the block. + * @returns The direction the block would need to travel to reach the new + * location. + */ + private getDirectionToNewLocation(newLocation: Coordinate): Direction { + const actualPosition = this.block.getRelativeToSurfaceXY(); + const delta = Coordinate.difference(newLocation, actualPosition); + const {x, y} = delta; + if (x) { + if (x < 0) { + return Direction.LEFT; + } else if (x > 0) { + return Direction.RIGHT; + } + } else if (y) { + if (y < 0) { + return Direction.UP; + } else if (y > 0) { + return Direction.DOWN; + } + } + return Direction.NONE; + } } From 9ccb4352c91d91133acef334c6c62cb0e30a5b8f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 11 Feb 2026 15:15:51 -0800 Subject: [PATCH 10/23] feat: Register keyboard shortcuts to drive movement --- packages/blockly/core/shortcut_items.ts | 148 +++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index f8c95500770..c53bdab411f 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -11,10 +11,20 @@ import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; +import { + type IBoundedElement, + isBoundedElement, +} from './interfaces/i_bounded_element.js'; import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; -import {isDraggable} from './interfaces/i_draggable.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {type IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import { + type IFocusableNode, + isFocusableNode, +} from './interfaces/i_focusable_node.js'; +import {type ISelectable, isSelectable} from './interfaces/i_selectable.js'; +import {Direction} from './keyboard_nav/keyboard_mover.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; @@ -386,6 +396,139 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +export function registerMovementShortcuts() { + const getCurrentDraggable = ( + workspace: WorkspaceSvg, + ): + | (IDraggable & IFocusableNode & IBoundedElement & ISelectable) + | undefined => { + const node = getFocusManager().getFocusedNode(); + if ( + isDraggable(node) && + isFocusableNode(node) && + isBoundedElement(node) && + isSelectable(node) + ) { + return node; + } + + return workspace.getCursor().getSourceBlock() ?? undefined; + }; + + const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + { + name: 'start_move', + preconditionFn: (workspace) => { + const startDraggable = getCurrentDraggable(workspace); + return ( + !!startDraggable && + workspace.getKeyboardMover().canMove(startDraggable) + ); + }, + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); + const startDraggable = getCurrentDraggable(workspace); + // Focus the root draggable in case one of its children + // was focused when the move was triggered. + if (startDraggable) { + getFocusManager().focusNode(startDraggable); + } + return ( + !!startDraggable && + workspace + .getKeyboardMover() + .startMove(startDraggable, e as KeyboardEvent) + ); + }, + keyCodes: [KeyCodes.M], + }, + { + name: 'finish_move', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().finishMove(e as KeyboardEvent), + keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], + allowCollision: true, + }, + { + name: 'abort_move', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().abortMove(e as KeyboardEvent), + keyCodes: [KeyCodes.ESC], + allowCollision: true, + }, + { + name: 'move_left', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().move(Direction.LEFT, e as KeyboardEvent), + keyCodes: [ + KeyCodes.LEFT, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [ + KeyCodes.ALT, + ]), + ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [ + KeyCodes.CTRL, + ]), + ], + allowCollision: true, + }, + { + name: 'move_right', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().move(Direction.RIGHT, e as KeyboardEvent), + keyCodes: [ + KeyCodes.RIGHT, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [ + KeyCodes.ALT, + ]), + ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [ + KeyCodes.CTRL, + ]), + ], + allowCollision: true, + }, + { + name: 'move_up', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().move(Direction.UP, e as KeyboardEvent), + keyCodes: [ + KeyCodes.UP, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ + KeyCodes.ALT, + ]), + ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ + KeyCodes.CTRL, + ]), + ], + allowCollision: true, + }, + { + name: 'move_down', + preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), + callback: (workspace, e) => + workspace.getKeyboardMover().move(Direction.DOWN, e as KeyboardEvent), + keyCodes: [ + KeyCodes.DOWN, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [ + KeyCodes.ALT, + ]), + ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [ + KeyCodes.CTRL, + ]), + ], + allowCollision: true, + }, + ]; + + for (const shortcut of shortcuts) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -400,6 +543,7 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); + registerMovementShortcuts(); } registerDefaultShortcuts(); From daff5ac3178d985ce60884aff37b2cc5a372dff5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 11 Feb 2026 15:58:13 -0800 Subject: [PATCH 11/23] feat: Display a move indicator on items that are being moved --- .../core/keyboard_nav/keyboard_mover.ts | 21 +++++ .../core/keyboard_nav/move_indicator.ts | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 packages/blockly/core/keyboard_nav/move_indicator.ts diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index fa94eb9a3b0..ad2a45eb9f4 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -14,6 +14,7 @@ import {ShortcutRegistry} from '../shortcut_registry.js'; import {Coordinate} from '../utils/coordinate.js'; import {KeyCodes} from '../utils/keycodes.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; +import {MoveIndicator} from './move_indicator.js'; /** * Cardinal directions in which a move can proceed. @@ -66,6 +67,11 @@ export class KeyboardMover { */ protected stepDistance = 20; + /** + * Symbol attached to the item being moved to indicate it is in move mode. + */ + protected moveIndicator?: MoveIndicator; + // Set up a blur listener to end the move if the user clicks away private readonly blurListener = () => { this.abortMove(); @@ -171,6 +177,8 @@ export class KeyboardMover { ShortcutRegistry.registry.register(commitMoveShortcut, true); this.scrollCurrentElementIntoView(); + this.moveIndicator = new MoveIndicator(this.workspace); + this.repositionMoveIndicator(); return true; } @@ -202,6 +210,7 @@ export class KeyboardMover { this.updateTotalDelta(); this.scrollCurrentElementIntoView(); + this.repositionMoveIndicator(); return true; } @@ -246,6 +255,16 @@ export class KeyboardMover { this.stepDistance = stepDistance; } + /** + * Repositions the move indicator to the corner of the item being moved. + */ + protected repositionMoveIndicator() { + const bounds = this.draggable?.getBoundingRectangle(); + if (!bounds) return; + + this.moveIndicator?.moveTo(bounds.right, bounds.top); + } + /** * Common clean-up for finish/abort run before terminating the move. */ @@ -264,6 +283,8 @@ export class KeyboardMover { protected postDragEndCleanup() { this.workspace.setKeyboardMoveInProgress(false); + this.moveIndicator?.dispose(); + this.moveIndicator = undefined; this.draggable = undefined; this.dragger = undefined; this.startLocation = undefined; diff --git a/packages/blockly/core/keyboard_nav/move_indicator.ts b/packages/blockly/core/keyboard_nav/move_indicator.ts new file mode 100644 index 00000000000..c445233f56f --- /dev/null +++ b/packages/blockly/core/keyboard_nav/move_indicator.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Four-way arrow indicator attached to a workspace element to indicate that it + * is being moved. + */ +export class MoveIndicator { + /** + * Root SVG element for the indicator. + */ + svgRoot: SVGGElement; + + /** + * Creates a new move indicator. + * + * @param workspace The workspace the indicator should be displayed on. + */ + constructor(workspace: WorkspaceSvg) { + this.svgRoot = dom.createSvgElement( + Svg.G, + {}, + workspace.getLayerManager()?.getDragLayer(), + ); + this.svgRoot.classList.add('blocklyMoveIndicator'); + const rtl = workspace.RTL; + dom.createSvgElement( + Svg.CIRCLE, + { + 'fill': 'white', + 'fill-opacity': '0.8', + 'stroke': 'grey', + 'stroke-width': '1', + 'r': 20, + 'cx': 20 * (rtl ? -1 : 1), + 'cy': 20, + }, + this.svgRoot, + ); + dom.createSvgElement( + Svg.PATH, + { + 'fill': 'none', + 'stroke': 'black', + '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, + ); + } + + /** + * Moves this indicator 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.svgRoot.setAttribute('transform', `translate(${x - 20}, ${y - 20})`); + } + + /** + * Disposes of this move indicator. + */ + dispose() { + dom.removeNode(this.svgRoot); + } +} From 16efc9f2ffe5aa58452ea1fe0202456b420dfd88 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 12 Feb 2026 13:38:21 -0800 Subject: [PATCH 12/23] fix: Reenable move hints --- .../core/dragging/block_drag_strategy.ts | 14 ++-- packages/blockly/core/hints.ts | 64 +++++++++++++++++++ packages/blockly/msg/json/en.json | 3 +- packages/blockly/msg/json/qqq.json | 1 + packages/blockly/msg/messages.js | 5 +- 5 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 packages/blockly/core/hints.ts diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 659f8c64cbe..6e572192d23 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -14,6 +14,7 @@ import {ConnectionType} from '../connection_type.js'; import type {BlockMove} from '../events/events_block_move.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import {showUnconstrainedMoveHint} from '../hints.js'; import type {IBubble} from '../interfaces/i_bubble.js'; import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; import type {IDragStrategy, IDraggable} from '../interfaces/i_draggable.js'; @@ -166,9 +167,7 @@ export class BlockDragStrategy implements IDragStrategy { } else { offset = new Coordinate(neighbour.x + 10, neighbour.y + 10); } - this.block.moveDuringDrag( - offset - ); + this.block.moveDuringDrag(offset); } } @@ -311,9 +310,9 @@ export class BlockDragStrategy implements IDragStrategy { // Handle the case when unconstrained drag was far from any candidate. this.searchNode = null; - // if (this.moveMode === MoveMode.CONSTRAINED) { - // showUnconstrainedMoveHint(this.workspace, true); - // } + if (this.moveMode === MoveMode.CONSTRAINED) { + showUnconstrainedMoveHint(this.workspace, true); + } } } @@ -667,7 +666,8 @@ export class BlockDragStrategy implements IDragStrategy { * start of the drag. */ private createInitialCandidate(): ConnectionCandidate | null { - this.searchNode = /* this.startPoint ?? */ this.startParentConn ?? this.startChildConn; + this.searchNode = + /* this.startPoint ?? */ this.startParentConn ?? this.startChildConn; switch (this.searchNode?.type) { case ConnectionType.INPUT_VALUE: { diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts new file mode 100644 index 00000000000..ac753f7b9d1 --- /dev/null +++ b/packages/blockly/core/hints.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Msg} from './msg.js'; +import {Toast} from './toast.js'; +import * as userAgent from './utils/useragent.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const unconstrainedMoveHintId = 'unconstrainedMoveHint'; +const constrainedMoveHintId = 'constrainedMoveHint'; + +/** + * Nudge the user to use unconstrained movement. + * + * @param workspace Workspace. + * @param force Set to show it even if previously shown. + */ +export function showUnconstrainedMoveHint( + workspace: WorkspaceSvg, + force = false, +) { + const modifier = + userAgent.MAC || userAgent.IPAD || userAgent.IPHONE + ? Msg['OPTION_KEY'] + : Msg['CONTROL_KEY']; + const message = Msg['KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT'] + .replace('%1', modifier) + .replace('%2', Msg['ENTER_KEY']); + Toast.show(workspace, { + message, + id: unconstrainedMoveHintId, + oncePerSession: !force, + }); +} + +/** + * Nudge the user to move a block that's in move mode. + * + * @param workspace Workspace. + */ +export function showConstrainedMovementHint(workspace: WorkspaceSvg) { + const message = Msg['KEYBOARD_NAV_CONSTRAINED_MOVE_HINT'].replace( + '%1', + Msg['ENTER_KEY'], + ); + Toast.show(workspace, { + message, + id: constrainedMoveHintId, + oncePerSession: true, + }); +} + +/** + * Clear active move-related hints, if any. + * + * @param workspace The workspace. + */ +export function clearMoveHints(workspace: WorkspaceSvg) { + Toast.hide(workspace, constrainedMoveHintId); + Toast.hide(workspace, unconstrainedMoveHintId); +} diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index efa06f10c71..81444375638 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-01-08 08:39:56.707280", + "lastupdated": "2026-02-12 13:23:33.999357", "locale": "en", "messagedocumentation" : "qqq" }, @@ -409,6 +409,7 @@ "COMMAND_KEY": "⌘ Command", "OPTION_KEY": "⌥ Option", "ALT_KEY": "Alt", + "ENTER_KEY": "Enter", "CUT_SHORTCUT": "Cut", "COPY_SHORTCUT": "Copy", "PASTE_SHORTCUT": "Paste", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 6912c7fd5f7..8e7be38b3e5 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -416,6 +416,7 @@ "COMMAND_KEY": "Representation of the Mac Command key used in keyboard shortcuts.", "OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.", "ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.", + "ENTER_KEY": "Representation of the Enter key used in keyboard shortcuts.", "CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.", "COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.", "PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 0cc4d3be455..6ae66c40a44 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1626,7 +1626,7 @@ Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents'; /// menu label - Contextual menu item that starts a keyboard-driven block move. Blockly.Msg.MOVE_BLOCK = 'Move Block'; /** @type {string} */ -/// Name of the Microsoft Windows operating system displayed in a list of +/// Name of the Microsoft Windows operating system displayed in a list of /// keyboard shortcuts. Blockly.Msg.WINDOWS = 'Windows'; /** @type {string} */ @@ -1658,6 +1658,9 @@ Blockly.Msg.OPTION_KEY = '⌥ Option'; /// Representation of the Alt key used in keyboard shortcuts. Blockly.Msg.ALT_KEY = 'Alt'; /** @type {string} */ +/// Representation of the Enter key used in keyboard shortcuts. +Blockly.Msg.ENTER_KEY = 'Enter'; +/** @type {string} */ /// menu label - Contextual menu item that cuts the focused item. Blockly.Msg.CUT_SHORTCUT = 'Cut'; /** @type {string} */ From 4139a3e32333c7c1364f8330e20740f92e8a72ca Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 13 Feb 2026 13:57:57 -0800 Subject: [PATCH 13/23] fix: Fix bugs that caused elements to be mispositioned by keyboard moves at non-default zoom levels --- .../blockly/core/comments/workspace_comment.ts | 2 +- .../blockly/core/keyboard_nav/keyboard_mover.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/blockly/core/comments/workspace_comment.ts b/packages/blockly/core/comments/workspace_comment.ts index b5dc3023cfe..19de2351220 100644 --- a/packages/blockly/core/comments/workspace_comment.ts +++ b/packages/blockly/core/comments/workspace_comment.ts @@ -221,7 +221,7 @@ export class WorkspaceComment { /** Returns the position of the comment in workspace coordinates. */ getRelativeToSurfaceXY(): Coordinate { - return this.location; + return this.location.clone(); } /** Disposes of this comment. */ diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index ad2a45eb9f4..ab02cab0292 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -206,7 +206,7 @@ export class KeyboardMover { break; } - this.dragger?.onDrag(event, this.totalDelta.clone()); + this.dragger?.onDrag(event, this.totalPixelDelta()); this.updateTotalDelta(); this.scrollCurrentElementIntoView(); @@ -224,7 +224,7 @@ export class KeyboardMover { finishMove(event?: KeyboardEvent | PointerEvent) { this.preDragEndCleanup(); - this.dragger?.onDragEnd(event, this.totalDelta); + this.dragger?.onDragEnd(event, this.totalPixelDelta()); this.postDragEndCleanup(); return true; @@ -239,7 +239,7 @@ export class KeyboardMover { abortMove(event?: KeyboardEvent | PointerEvent) { this.preDragEndCleanup(); - this.dragger?.onDragRevert(event, this.totalDelta); + this.dragger?.onDragRevert(event, this.totalPixelDelta()); this.postDragEndCleanup(); return true; @@ -291,6 +291,14 @@ export class KeyboardMover { this.totalDelta = new Coordinate(0, 0); } + /** + * Returns the total distance current element has moved in pixels. + */ + protected totalPixelDelta() { + const scale = this.workspace.scale; + return new Coordinate(this.totalDelta.x * scale, this.totalDelta.y * scale); + } + /** * Scrolls the current element into view. */ From 44739ed143b89357423eb50268a8b7804737680b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 10:39:57 -0800 Subject: [PATCH 14/23] fix: Fix a bug that caused certain connections to be visited out of order --- .../core/dragging/block_drag_strategy.ts | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 6e572192d23..4b406a53158 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -137,19 +137,7 @@ export class BlockDragStrategy implements IDragStrategy { // use in constrained moved mode. if (e instanceof KeyboardEvent) { for (const topBlock of this.block.workspace.getTopBlocks(true)) { - this.allConnections.push( - ...topBlock - .getDescendants(true) - .filter((block: BlockSvg) => !block.isShadow()) - .flatMap((block: BlockSvg) => block.getConnections_(false)) - .sort((a: RenderedConnection, b: RenderedConnection) => { - let delta = a.y - b.y; - if (delta === 0) { - delta = a.x - b.x; - } - return delta; - }), - ); + this.allConnections.push(...this.getAllConnections(topBlock)); } // Scooch the block to be offset from the connection preview indicator. @@ -734,4 +722,44 @@ export class BlockDragStrategy implements IDragStrategy { } return Direction.NONE; } + + /** + * Returns all navigable connections on the given block and its children. + * Omits connections on shadow blocks, collapsed blocks, or those that are + * associated with a hidden input. + * + * @param block The block to use as a starting point for retrieving + * connections. + * @returns All connections on the block and its children. + */ + private getAllConnections(block: BlockSvg): RenderedConnection[] { + if (block.isShadow()) return []; + + const connections = []; + + if (block.outputConnection) connections.push(block.outputConnection); + if (block.previousConnection) connections.push(block.previousConnection); + + if (!block.isCollapsed()) { + for (const input of block.inputList) { + if (input.connection && input.isVisible()) { + connections.push(input.connection); + const target = input.connection.targetBlock() as BlockSvg; + if (target) { + connections.push(...this.getAllConnections(target)); + } + } + } + } + if (block.nextConnection) { + connections.push(block.nextConnection); + + const target = block.nextConnection.targetBlock() as BlockSvg; + if (target) { + connections.push(...this.getAllConnections(target)); + } + } + + return connections as RenderedConnection[]; + } } From e0ddebb440ca633cd83069952817ec66b9924e8c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 10:40:25 -0800 Subject: [PATCH 15/23] fix: Fix a bug that caused blocks to become disconnected during constrained moves --- packages/blockly/core/dragging/block_drag_strategy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 4b406a53158..bf9172dc60f 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -631,7 +631,8 @@ export class BlockDragStrategy implements IDragStrategy { localConns.forEach((conn: RenderedConnection) => { if ( potential && - connectionChecker.canConnect(conn, potential, true, Infinity) + connectionChecker.canConnect(conn, potential, true, Infinity) && + !potential.targetBlock()?.isInsertionMarker() ) { candidateConnection = { local: conn, From 79ea540a0b4d685ba40c0ffd6d5a9c8f914c5cc4 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 10:51:36 -0800 Subject: [PATCH 16/23] test: Add tests for keyboard-driven movement --- packages/blockly/tests/mocha/index.html | 1 + .../tests/mocha/keyboard_movement_test.js | 810 ++++++++++++++++++ .../tests/mocha/test_helpers/fixtures.js | 529 ++++++++++++ .../tests/mocha/test_helpers/p5_blocks.js | 358 ++++++++ 4 files changed, 1698 insertions(+) create mode 100644 packages/blockly/tests/mocha/keyboard_movement_test.js create mode 100644 packages/blockly/tests/mocha/test_helpers/fixtures.js create mode 100644 packages/blockly/tests/mocha/test_helpers/p5_blocks.js diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 8dd5417ebe0..012bfe201ca 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -219,6 +219,7 @@ import './jso_deserialization_test.js'; import './jso_serialization_test.js'; import './json_test.js'; + import './keyboard_movement_test.js'; import './keyboard_navigation_controller_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js new file mode 100644 index 00000000000..b50042faa95 --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -0,0 +1,810 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/index.js'; +import { + moveStatementTestBlocks, + moveValueTestBlocks, +} from './test_helpers/fixtures.js'; +import {p5blocks} from './test_helpers/p5_blocks.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; + +suite('Keyboard-driven movement', function () { + setup(function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox: toolbox}); + Blockly.common.defineBlocks(p5blocks); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + function startMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.M); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveUp(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveDown(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.DOWN, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveLeft(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.LEFT, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveRight(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.RIGHT, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function cancelMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ESC); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function endMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + workspace.getInjectionDiv().dispatchEvent(event); + } + + /** + * Create a new block from serialised state (parsed JSON) and + * optionally attach it to an existing block on the workspace. + * + * @param workspace The workspace to create the block on. + * @param state The JSON definition of the new block. + * @param parentId The ID of the block to attach to. If undefined, the + * new block is not attached. + * @param inputName The name of the input on the parent block to + * attach to. If undefined, the new block is attached to the + * parent's next connection. + * @returns A promise that resolves with the new block's ID. + */ + function appendBlock(workspace, state, parentId, inputName) { + const block = Blockly.serialization.blocks.append(state, workspace); + if (!block) throw new Error('failed to create block from state'); + if (!parentId) return block.id; + + try { + const parent = workspace.getBlockById(parentId); + if (!parent) throw new Error(`parent block not found: ${parentId}`); + + let parentConnection; + let childConnection; + + if (inputName) { + parentConnection = parent.getInput(inputName)?.connection; + if (!parentConnection) { + throw new Error(`input ${inputName} not found on parent`); + } + childConnection = block.outputConnection ?? block.previousConnection; + } else { + parentConnection = parent.nextConnection; + if (!parentConnection) { + throw new Error('parent has no next connection'); + } + childConnection = block.previousConnection; + } + if (!childConnection) throw new Error('new block not compatible'); + parentConnection.connect(childConnection); + return block.id; + } catch (e) { + // If anything goes wrong during attachment, clean up the new block. + block.dispose(); + throw e; + } + } + + /** + * Get information about the currently-focused block's parent and + * child blocks. + * + * @returns A promise resolving to + * + * {parentId, parentIndex, nextId, valueId} + * + * where parentId, parentIndex are the ID of the parent block and + * the index of the connection on that block to which the + * currently-focused block is connected, nextId is the ID of block + * connected to the focused block's next connection, and valueID + * is the ID of a block connected to the zeroth input of the + * focused block (or, in each case, null if there is no such + * block). + */ + function getFocusedNeighbourInfo() { + return Blockly.renderManagement.finishQueuedRenders().then(() => { + const block = Blockly.getFocusManager().getFocusedNode(); + if (!block) throw new Error('nothing focused'); + if (!(block instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const parent = block?.getParent(); + return { + parentId: parent?.id ?? null, + parentIndex: + parent + ?.getConnections_(true) + .findIndex((conn) => conn.targetBlock() === block) ?? null, + nextId: block?.getNextBlock()?.id ?? null, + valueId: block?.inputList[0].connection?.targetBlock()?.id ?? null, + }; + }); + } + + /** + * Get information about the connection candidate for the + * currently-moving block (if any). + * + * @returns A promise resolving to either null if there is no connection + * candidate, or otherwise if there is one to + * + * {id, index, ownIndex} + * + * where id is the block ID of the neighbour, index is the index + * of the candidate connection on the neighbour, and ownIndex is + * the index of the candidate connection on the moving block. + */ + function getConnectionCandidate() { + const focused = Blockly.getFocusManager().getFocusedNode(); + if (!focused) throw new Error('nothing focused'); + if (!(focused instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const block = focused; // Inferred as BlockSvg. + const dragStrategy = block.getDragStrategy(); + if (!dragStrategy) throw new Error('no drag strategy'); + const candidate = dragStrategy.connectionCandidate; + if (!candidate) return null; + const neighbourBlock = candidate.neighbour.getSourceBlock(); + if (!neighbourBlock) throw new TypeError('connection has no source block'); + const neighbourConnections = neighbourBlock.getConnections_(true); + const index = neighbourConnections.indexOf(candidate.neighbour); + const ownConnections = block.getConnections_(true); + const ownIndex = ownConnections.indexOf(candidate.local); + return {id: neighbourBlock.id, index, ownIndex}; + } + + /** + * Create a mocha test function moving a specified block in a + * particular direction, checking that it has the the expected + * connection candidate after each step, and that once the move + * finishes that the moving block is reconnected to its initial + * location. + * + * @param mover Block ID of the block to be moved. + * @param key Key to send to move one step. + * @param candidates Array of expected connection candidates. + * @returns function to pass as second argument to mocha's test function. + */ + function moveTest(mover, key, candidates) { + return async function () { + // Navigate to block to be moved and initiate move. + const block = this.workspace.getBlockById(mover); + Blockly.getFocusManager().focusNode(block); + const initialInfo = await getFocusedNeighbourInfo(); + startMove(this.workspace); + // Press specified key multiple times, checking connection candidates. + for (let i = 0; i < candidates.length; i++) { + const candidate = getConnectionCandidate(); + assert.deepEqual(candidate, candidates[i]); + const event = createKeyDownEvent(key); + this.workspace.getInjectionDiv().dispatchEvent(event); + } + + // Finish move and check final location of moved block. + endMove(this.workspace); + const finalInfo = await getFocusedNeighbourInfo(); + assert.deepEqual(initialInfo, finalInfo); + }; + } + + function testMovingUp() { + test('can move them up', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveUp(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isBelow(newBounds.top, originalBounds.top); + assert.equal(newBounds.left, originalBounds.left); + }); + } + + function testMovingDown() { + test('can move them down', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveDown(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isAbove(newBounds.bottom, originalBounds.bottom); + assert.equal(newBounds.left, originalBounds.left); + }); + } + + function testMovingLeft() { + test('can move them left', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveLeft(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isBelow(newBounds.left, originalBounds.left); + assert.equal(newBounds.top, originalBounds.top); + }); + } + + function testMovingRight() { + test('can move them right', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isAbove(newBounds.right, originalBounds.right); + assert.equal(newBounds.top, originalBounds.top); + }); + } + + function testCancelingMove() { + test('can be cancelled', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + moveUp(this.workspace, this.modifiers); + cancelMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.deepEqual(newBounds, originalBounds); + }); + } + + function testMoveIndicatorIsDisplayed() { + test('displays an attached move indicator while moving', function () { + Blockly.getFocusManager().focusNode(this.element); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 0, + ); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 1, + ); + endMove(this.workspace); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 0, + ); + }); + } + + function testAdjustingMoveStepSize() { + test('respects configured step size', function () { + Blockly.getFocusManager().focusNode(this.element); + startMove(this.workspace); + const steps = [100, 20, 0, -20, -100]; + for (const step of steps) { + this.workspace.getKeyboardMover().setMoveDistance(step); + const oldLeft = this.element.getBoundingRectangle().left; + moveRight(this.workspace, this.modifiers); + const newLeft = this.element.getBoundingRectangle().left; + assert.equal(newLeft - oldLeft, step); + } + }); + } + + function testUnrelatedShortcutCommits() { + test('is committed when unrelated shortcuts are performed', function () { + const oldBounds = this.element.getBoundingRectangle(); + Blockly.getFocusManager().focusNode(this.element); + startMove(this.workspace); + assert.isTrue(this.workspace.getKeyboardMover().isMoving()); + moveRight(this.workspace, this.modifiers); + moveRight(this.workspace, this.modifiers); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.META, + ]); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.isFalse(this.workspace.getKeyboardMover().isMoving()); + + const newBounds = this.element.getBoundingRectangle(); + oldBounds.left += 40; + oldBounds.right += 40; + assert.deepEqual(newBounds, oldBounds); + }); + } + + suite('of workspace comments', function () { + setup(function () { + this.element = new Blockly.comments.RenderedWorkspaceComment( + this.workspace, + ); + }); + + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + }); + + suite('of blocks', function () { + setup(function () { + this.element = this.workspace.newBlock('logic_boolean'); + this.element.initSvg(); + this.element.render(); + this.modifiers = [Blockly.utils.KeyCodes.ALT]; + }); + + suite('in unconstrained mode', function () { + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + }); + + suite('in constrained mode', function () { + test('prompts to use unconstrained mode when no destinations are available', function () { + const toastSpy = sinon.spy(Blockly.Toast, 'show'); + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveUp(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.deepEqual(newBounds, originalBounds); + assert.equal( + toastSpy.args[0][1]['message'], + 'Hold ⌥ Option and use arrow keys to move freely, then Enter to accept the position', + ); + toastSpy.restore(); + }); + + suite('Statement move tests', function () { + // Clear the workspace and load start blocks. + setup(function () { + Blockly.serialization.workspaces.load( + moveStatementTestBlocks, + this.workspace, + ); + }); + + /** Serialized simple statement block with no statement inputs. */ + const STATEMENT_SIMPLE = { + type: 'draw_emoji', + id: 'simple_mover', + fields: {emoji: '✨'}, + }; + /** + * Expected connection candidates when moving a block with no + * inputs, after pressing right (or down) arrow n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location. + {id: 'text_print', index: 0, ownIndex: 1}, // Previous. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_SIMPLE after + * pressing left (or up) arrow n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + suite('Constrained moves of simple statement block', function () { + setup(function () { + appendBlock(this.workspace, STATEMENT_SIMPLE, 'p5_canvas'); + }); + test( + 'moving right', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + test( + 'moving down', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.DOWN, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving up', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.UP, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + /** Serialized statement block with multiple statement inputs. */ + const STATEMENT_COMPLEX = { + type: 'controls_if', + id: 'complex_mover', + extraState: {hasElse: true}, + }; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX, after + * pressing right (or down) arrow n times. + */ + const EXPECTED_COMPLEX_RIGHT = [ + // TODO(#702): With the current behavior, certain connection + // candidates that can be found using the mouse are not visited when + // doing a keyboard move. They appear in the list below, but commented + // out for now. They should be uncommented if the behavior is changed. + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. + // {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. + {id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input. + // {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX after + * pressing left or up arrow n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + suite( + 'Constrained moves of stack block with statement inputs', + function () { + setup(function () { + appendBlock(this.workspace, STATEMENT_COMPLEX, 'p5_canvas'); + }); + test( + 'moving right', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_COMPLEX_LEFT, + ), + ); + test( + 'moving down', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.DOWN, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving up', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.UP, + EXPECTED_COMPLEX_LEFT, + ), + ); + }, + ); + + // When a top-level block with no previous, next or output + // connections is subject to a constrained move, it should not move. + // + // This includes a regression test for issue #446 (fixed in PR #599) + // where, due to an implementation error in Mover, constrained + // movement following unconstrained movement would result in the + // block unexpectedly moving (unless workspace scale was === 1). + test('Constrained move of unattachable top-level block', async function () { + // Block ID of an unconnectable block. + const BLOCK = Blockly.getMainWorkspace().getBlockById('p5_setup'); + + // Scale workspace. + this.workspace.setScale(0.9); + + // Navigate to unconnectable block, get initial coords and start move. + Blockly.getFocusManager().focusNode(BLOCK); + const startCoordinate = BLOCK.getBoundingRectangle(); + startMove(this.workspace); + + // Check constrained moves have no effect. + for (let i = 0; i < 5; i++) { + moveDown(this.workspace); + } + const coordinate = BLOCK.getBoundingRectangle(); + assert.deepEqual( + coordinate, + startCoordinate, + 'constrained move should have no effect', + ); + cancelMove(this.workspace); + }); + }); + + suite(`Value expression move tests`, function () { + /** Serialized simple reporter value block with no inputs. */ + const VALUE_SIMPLE = { + type: 'text', + id: 'simple_mover', + fields: {TEXT: 'simple mover'}, + }; + /** + * Expected connection candidates when moving VALUE_SIMPLE, after + * pressing ArrowRight n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'join0', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join0', index: 2, ownIndex: 0}, // Join block ADD1 input. + {id: 'print1', index: 2, ownIndex: 0}, // Print block with no shadow. + {id: 'print2', index: 2, ownIndex: 0}, // Print block with shadow. + // Skip draw_emoji block as it has no value inputs. + {id: 'print3', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join1', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join1', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip controls_repeat_ext block's TIMES input as it is incompatible. + {id: 'print4', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join2', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join2', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip input of unattached join block. + ]; + /** + * Expected connection candidates when moving BLOCK_SIMPLE, after + * pressing ArrowLeft n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + /** + * Serialized row of value blocks with no free inputs; should behave + * as VALUE_SIMPLE does. + */ + const VALUE_ROW = { + type: 'text_changeCase', + id: 'row_mover', + fields: {CASE: 'TITLECASE'}, + inputs: { + TEXT: {block: VALUE_SIMPLE}, + }, + }; + // EXPECTED_ROW_RIGHT will be same as EXPECTED_SIMPLE_RIGHT (and + // same for ..._LEFT). + + /** Serialized value block with a single free (external) input. */ + const VALUE_UNARY = { + type: 'text_changeCase', + id: 'unary_mover', + fields: {CASE: 'TITLECASE'}, + }; + /** + * Expected connection candidates when moving VALUE_UNARY after + * pressing ArrowRight n times. + */ + const EXPECTED_UNARY_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_UNARY on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_UNARY_LEFT = EXPECTED_UNARY_RIGHT.slice(0, 1).concat( + EXPECTED_UNARY_RIGHT.slice(1).reverse(), + ); + + /** Serialized value block with a single free (external) input. */ + const VALUE_COMPLEX = { + type: 'text_join', + id: 'complex_mover', + }; + /** + * Expected connection candidates when moving VALUE_COMPLEX after + * pressing ArrowRight n times. + */ + const EXPECTED_COMPLEX_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + // TODO(#702): With the current behavior, certain connection + // candidates that can be found using the mouse are not visited when + // doing a keyboard move. They appear in the list below, but commented + // out for now. They should be uncommented if the behavior is changed. + {id: 'join0', index: 0, ownIndex: 2}, // Unattached block to own input. + // {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_COMPLEX on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + for (const renderer of ['geras', 'thrasos', 'zelos']) { + suite(`using ${renderer}`, function () { + // Clear the workspace and load start blocks. + setup(function () { + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: renderer, + }); + Blockly.serialization.workspaces.load( + moveValueTestBlocks, + this.workspace, + ); + }); + + suite('Constrained moves of a simple reporter block', function () { + setup(function () { + appendBlock(this.workspace, VALUE_SIMPLE, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_SIMPLE.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_SIMPLE.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + suite('Constrained moves of row of value blocks', function () { + setup(function () { + appendBlock(this.workspace, VALUE_ROW, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_ROW.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_ROW.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + suite('Constrained moves of unary expression block', function () { + setup(function () { + appendBlock(this.workspace, VALUE_UNARY, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_UNARY.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_UNARY_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_UNARY.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_UNARY_LEFT, + ), + ); + }); + + suite( + 'Constrained moves of a complex expression block', + function () { + setup(function () { + appendBlock(this.workspace, VALUE_COMPLEX, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_COMPLEX.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_COMPLEX.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_COMPLEX_LEFT, + ), + ); + }, + ); + }); + } + }); + }); + }); + + suite('of bubbles', function () { + setup(async function () { + const commentBlock = this.workspace.newBlock('logic_boolean'); + commentBlock.setCommentText('Hello world'); + const icon = commentBlock.getIcon(Blockly.icons.IconType.COMMENT); + await icon.setBubbleVisible(true); + this.element = icon.getBubble(); + }); + + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + }); +}); diff --git a/packages/blockly/tests/mocha/test_helpers/fixtures.js b/packages/blockly/tests/mocha/test_helpers/fixtures.js new file mode 100644 index 00000000000..4800774f2a1 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/fixtures.js @@ -0,0 +1,529 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + + +// The draw block contains a stack of statement blocks, each of which +// has a value input to which is connected a value expression block +// which itself has one or two inputs which have (non-shadow) simple +// value blocks connected. Each statement block will be selected in +// turn and then a move initiated (and then aborted). This is then +// repeated with the first level value blocks (those that are attached +// to the statement blocks). The second level value blocks are +// present to verify correct (lack of) heal behaviour. +const moveStartTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup_1', + 'x': 0, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas_1', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw_1', + 'x': 0, + 'y': 332, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'controls_if', + 'id': 'statement_1', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_operation', + 'id': 'value_1', + 'fields': { + 'OP': 'AND', + }, + 'inputs': { + 'A': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_1_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'B': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_1_2', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'statement_2', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_negate', + 'id': 'value_2', + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_2_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'statement_3', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_arithmetic', + 'id': 'value_3', + 'fields': { + 'OP': 'ADD', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3_1', + 'fields': { + 'NUM': 1, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_3_1', + 'fields': { + 'NUM': 0, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3_2', + 'fields': { + 'NUM': 1, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_3_2', + 'fields': { + 'NUM': 0, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'statement_4', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_4', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_trig', + 'id': 'value_4', + 'fields': { + 'OP': 'SIN', + }, + 'inputs': { + 'NUM': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_4_1', + 'fields': { + 'NUM': 45, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_4_1', + 'fields': { + 'NUM': 180, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'statement_5', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_5', + 'fields': { + 'TEXT': 'abc', + }, + }, + 'block': { + 'type': 'text_join', + 'id': 'value_5', + 'extraState': { + 'itemCount': 2, + }, + 'inputs': { + 'ADD0': { + 'block': { + 'type': 'text', + 'id': 'value_5_1', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + 'ADD1': { + 'block': { + 'type': 'text', + 'id': 'value_5_2', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'statement_6', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_6', + 'fields': { + 'TEXT': 'abc', + }, + }, + 'block': { + 'type': 'text_reverse', + 'id': 'value_6', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_6_1', + 'fields': { + 'TEXT': '', + }, + }, + 'block': { + 'type': 'text', + 'id': 'value_6_1', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'statement_7', + 'fields': { + 'emoji': '❤️', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; + +// A bunch of statement blocks. It is intended that statement blocks +// to be moved can be attached to the next connection of p5_canvas, +// and then be (constrained-)moved up, down, left and right to verify +// that they visit all the expected candidate connections. +const moveStatementTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_print', + 'id': 'text_print', + 'disabledReasons': ['MANUALLY_DISABLED'], + 'x': 75, + 'y': 400, + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if', + 'extraState': { + 'elseIfCount': 1, + 'hasElse': true, + }, + 'inputs': { + 'DO0': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 950, + 'deletable': false, + }, + ], + }, +}; + +const moveValueTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_join', + 'id': 'join0', + 'x': 75, + 'y': 200, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 300, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'text_print', + 'id': 'print1', + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print2', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_print2', + 'fields': { + 'TEXT': 'shadow', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji', + 'fields': { + 'emoji': '🐻', + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print3', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join1', + 'inline': true, + 'inputs': { + 'ADD0': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_join', + 'fields': { + 'TEXT': 'inline', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_repeat', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'text_print', + 'id': 'print4', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join2', + 'inline': false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; + +export {moveStartTestBlocks, moveStatementTestBlocks, moveValueTestBlocks}; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/test_helpers/p5_blocks.js b/packages/blockly/tests/mocha/test_helpers/p5_blocks.js new file mode 100644 index 00000000000..ad3c6a293e5 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/p5_blocks.js @@ -0,0 +1,358 @@ +/* eslint-disable camelcase */ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../../build/src/core/blockly.js'; + +// p5 Basic Setup Blocks + +const p5SetupJson = { + 'type': 'p5_setup', + 'message0': 'setup %1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENTS', + }, + ], + 'colour': 300, + 'tooltip': 'Setup the p5 canvas. This code is run once.', + 'helpUrl': '', +}; + +const p5Setup = { + init: function () { + this.jsonInit(p5SetupJson); + // The setup block can't be removed. + this.setDeletable(false); + }, +}; + +const p5DrawJson = { + 'type': 'p5_draw', + 'message0': 'draw %1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENTS', + }, + ], + 'colour': 300, + 'tooltip': 'Draw on the canvas. This code is run continuously.', + 'helpUrl': '', +}; + +const p5Draw = { + init: function () { + this.jsonInit(p5DrawJson); + // The draw block can't be removed. + this.setDeletable(false); + }, +}; + +const p5CanvasJson = { + 'type': 'p5_canvas', + 'message0': 'create canvas with width %1 height %2', + 'args0': [ + { + 'type': 'field_number', + 'name': 'WIDTH', + 'value': 400, + 'max': 400, + 'precision': 1, + }, + { + 'type': 'field_number', + 'name': 'HEIGHT', + 'value': 400, + 'max': 400, + 'precision': 1, + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 300, + 'tooltip': 'Create a p5 canvas of the specified size.', + 'helpUrl': '', +}; + +const p5Canvas = { + init: function () { + this.jsonInit(p5CanvasJson); + // The canvas block can't be moved or disconnected from its parent. + this.setMovable(false); + this.setDeletable(false); + }, +}; + +const buttonsJson = { + 'type': 'buttons', + 'message0': 'If %1 %2 Then %3 %4 more %5 %6 %7', + 'args0': [ + { + 'type': 'field_image', + 'name': 'BUTTON1', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + { + 'type': 'input_value', + 'name': 'VALUE1', + 'check': '', + }, + { + 'type': 'field_image', + 'name': 'BUTTON2', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + { + 'type': 'input_dummy', + 'name': 'DUMMY1', + 'check': '', + }, + { + 'type': 'input_value', + 'name': 'VALUE2', + 'check': '', + }, + { + 'type': 'input_statement', + 'name': 'STATEMENT1', + 'check': 'Number', + }, + { + 'type': 'field_image', + 'name': 'BUTTON3', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', +}; + +const buttonsBlock = { + init: function () { + this.jsonInit(buttonsJson); + const clickHandler = function () { + console.log('clicking a button!'); + }; + this.getField('BUTTON1').setOnClickHandler(clickHandler); + this.getField('BUTTON2').setOnClickHandler(clickHandler); + this.getField('BUTTON3').setOnClickHandler(clickHandler); + }, +}; + +const background = { + 'type': 'p5_background_color', + 'message0': 'Set background color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the background color of the canvas', + 'helpUrl': '', +}; + +const stroke = { + 'type': 'p5_stroke', + 'message0': 'Set stroke color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the stroke color', + 'helpUrl': '', +}; + +const fill = { + 'type': 'p5_fill', + 'message0': 'Set fill color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the fill color', + 'helpUrl': '', +}; + +const ellipse = { + 'type': 'p5_ellipse', + 'message0': 'draw ellipse %1 x %2 y %3 width %4 height %5', + 'args0': [ + { + 'type': 'input_dummy', + }, + { + 'type': 'input_value', + 'name': 'X', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'Y', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'WIDTH', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'HEIGHT', + 'check': 'Number', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': 'Draw an ellipse on the canvas.', + 'helpUrl': 'https://p5js.org/reference/#/p5/ellipse', +}; + +const draw_emoji = { + 'type': 'draw_emoji', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'draw %1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'emoji', + 'options': [ + ['❤️', '❤️'], + ['✨', '✨'], + ['🐻', '🐻'], + ], + }, + { + 'type': 'input_dummy', + 'name': '', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'inputsInline': true, +}; + +const simpleCircle = { + 'type': 'simple_circle', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'draw %1 circle %2', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + { + 'type': 'input_dummy', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'inputsInline': true, +}; + +const writeTextWithoutShadow = { + 'type': 'write_text_without_shadow', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'write without shadow %1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'bit', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 225, +}; + +const writeTextWithShadow = { + 'type': 'write_text_with_shadow', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'write with shadow %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'TEXT', + 'check': 'String', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 225, +}; + +const textBlock = { + 'type': 'text_only', + 'tooltip': '', + 'helpUrl': '', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'micro', + }, + ], + 'output': 'String', + 'colour': 225, +}; + +// Create the block definitions for all the JSON-only blocks. +// This does not register their definitions with Blockly. +const jsonBlocks = Blockly.common.createBlockDefinitionsFromJsonArray([ + background, + stroke, + fill, + ellipse, + draw_emoji, + simpleCircle, + writeTextWithoutShadow, + writeTextWithShadow, + textBlock, +]); + +export const p5blocks = { + 'p5_setup': p5Setup, + 'p5_draw': p5Draw, + 'p5_canvas': p5Canvas, + 'buttons_block': buttonsBlock, + ...jsonBlocks, +}; From d6fe8e80f817f50983eb95162364bcb1ded99e0e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 11:36:30 -0800 Subject: [PATCH 17/23] chore: Add exports --- packages/blockly/core/blockly.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 33b85923500..7377ff9098b 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -118,6 +118,8 @@ import * as icons from './icons.js'; import {inject} from './inject.js'; import * as inputs from './inputs.js'; import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; +import {MoveIndicator} from './keyboard_nav/move_indicator.js'; import {LabelFlyoutInflater} from './label_flyout_inflater.js'; import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js'; import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; @@ -504,6 +506,7 @@ export { BlockFlyoutInflater, ButtonFlyoutInflater, CodeGenerator, + Direction, DragDisposition, Field, FieldCheckbox, @@ -589,6 +592,7 @@ export { ImageProperties, Input, InsertionMarkerPreviewer, + KeyboardMover, KeyboardNavigationController, LabelFlyoutInflater, LayerManager, @@ -600,6 +604,7 @@ export { MenuItem, MenuOption, MetricsManager, + MoveIndicator, Msg, Names, Options, From 5bbc7428a9f267ea0585cc692fa6daac4e189e30 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 11:36:35 -0800 Subject: [PATCH 18/23] chore: Run formatter --- packages/blockly/tests/mocha/test_helpers/fixtures.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/blockly/tests/mocha/test_helpers/fixtures.js b/packages/blockly/tests/mocha/test_helpers/fixtures.js index 4800774f2a1..c29dab7e709 100644 --- a/packages/blockly/tests/mocha/test_helpers/fixtures.js +++ b/packages/blockly/tests/mocha/test_helpers/fixtures.js @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ - // The draw block contains a stack of statement blocks, each of which // has a value input to which is connected a value expression block // which itself has one or two inputs which have (non-shadow) simple @@ -526,4 +525,4 @@ const moveValueTestBlocks = { }, }; -export {moveStartTestBlocks, moveStatementTestBlocks, moveValueTestBlocks}; \ No newline at end of file +export {moveStartTestBlocks, moveStatementTestBlocks, moveValueTestBlocks}; From a424017dfedb51229a35efad918609adb3931a6a Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 11:55:37 -0800 Subject: [PATCH 19/23] chore: Make the linter happy --- packages/blockly/core/bubbles/bubble.ts | 4 +-- packages/blockly/core/shortcut_items.ts | 4 +++ .../tests/mocha/keyboard_movement_test.js | 30 +++++++++++-------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 24630048d02..422c2b9ea9b 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -283,9 +283,9 @@ export abstract class Bubble * * @param dx The distance to move along the x axis. * @param dy The distance to move along the y axis. - * @param reason A description of why this move is happening. + * @param _reason A description of why this move is happening. */ - moveBy(dx: number, dy: number, reason?: string[]) { + moveBy(dx: number, dy: number, _reason?: string[]) { const origin = this.getRelativeToSurfaceXY(); this.moveTo(origin.x + dx, origin.y + dy); } diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index c53bdab411f..aa1bdf73456 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -396,6 +396,10 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Registers keyboard shortcuts for keyboard-driven movement of workspace + *elements. + */ export function registerMovementShortcuts() { const getCurrentDraggable = ( workspace: WorkspaceSvg, diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index b50042faa95..8441f607c3f 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -68,14 +68,17 @@ suite('Keyboard-driven movement', function () { * Create a new block from serialised state (parsed JSON) and * optionally attach it to an existing block on the workspace. * - * @param workspace The workspace to create the block on. - * @param state The JSON definition of the new block. - * @param parentId The ID of the block to attach to. If undefined, the - * new block is not attached. - * @param inputName The name of the input on the parent block to + * @param {!Blockly.WorkspaceSvg} workspace The workspace to create the block + * on. + * @param {!Blockly.serialization.blocks.State} state The JSON definition of + * the new block. + * @param {?string} parentId The ID of the block to attach to. If undefined, + * the new block is not attached. + * @param {?string} inputName The name of the input on the parent block to * attach to. If undefined, the new block is attached to the * parent's next connection. - * @returns A promise that resolves with the new block's ID. + * @returns {!Promise} A promise that resolves with the new block's + * ID. */ function appendBlock(workspace, state, parentId, inputName) { const block = Blockly.serialization.blocks.append(state, workspace); @@ -116,7 +119,7 @@ suite('Keyboard-driven movement', function () { * Get information about the currently-focused block's parent and * child blocks. * - * @returns A promise resolving to + * @returns {!Promise<{parentId: string | null, parentIndex: number | null, nextId: string | null, valueId: string | null}>} A promise resolving to * * {parentId, parentIndex, nextId, valueId} * @@ -152,7 +155,8 @@ suite('Keyboard-driven movement', function () { * Get information about the connection candidate for the * currently-moving block (if any). * - * @returns A promise resolving to either null if there is no connection + * @returns {!Promise<{id: string, index: number, ownIndex: number} | null>} A + * promise resolving to either null if there is no connection * candidate, or otherwise if there is one to * * {id, index, ownIndex} @@ -188,10 +192,12 @@ suite('Keyboard-driven movement', function () { * finishes that the moving block is reconnected to its initial * location. * - * @param mover Block ID of the block to be moved. - * @param key Key to send to move one step. - * @param candidates Array of expected connection candidates. - * @returns function to pass as second argument to mocha's test function. + * @param {!string} mover Block ID of the block to be moved. + * @param {!Blockly.utils.KeyCodes} key Key to send to move one step. + * @param {!Blockly.RenderedConnection[]} candidates Array of expected + * connection candidates. + * @returns {!function} function to pass as second argument to mocha's test + * function. */ function moveTest(mover, key, candidates) { return async function () { From 691dda18adf825eed89ceeb5393d7e50de932b4d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 11:59:23 -0800 Subject: [PATCH 20/23] chore: Update closure compiler --- package-lock.json | 68 ++++++++++++++++++++++++++++++----- packages/blockly/package.json | 2 +- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec7367de90b..12a16e969b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,12 +110,14 @@ } }, "node_modules/google-closure-compiler": { - "version": "20260114.0.0", + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260211.0.0.tgz", + "integrity": "sha512-Q/SSQiK7mfkIKCw3QCDGpKUYExfu8pCN7R0kXMKaIICtbFulsdIl3bx4OqMnu+KSE9oMtgBdap7oP/MdnprAXg==", "dev": true, "license": "Apache-2.0", "dependencies": { "chalk": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 <5.6.1 || ^5.6.2 >5.6.1", - "google-closure-compiler-java": "^20260114.0.0", + "google-closure-compiler-java": "^20260211.0.0", "minimist": "^1.0.0", "vinyl": "^3.0.1", "vinyl-sourcemaps-apply": "^0.2.0" @@ -127,19 +129,52 @@ "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20260114.0.0", - "google-closure-compiler-linux-arm64": "^20260114.0.0", - "google-closure-compiler-macos": "^20260114.0.0", - "google-closure-compiler-windows": "^20260114.0.0" + "google-closure-compiler-linux": "^20260211.0.0", + "google-closure-compiler-linux-arm64": "^20260211.0.0", + "google-closure-compiler-macos": "^20260211.0.0", + "google-closure-compiler-windows": "^20260211.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20260114.0.0", + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260211.0.0.tgz", + "integrity": "sha512-KTs8W5e0JKenM2w4aiab2axlekb3OScWKzIIR3ImvdImR2YfOF4obJKZUsOjQd3HbXfQGLdMdN8D0sOKRkh5zQ==", "dev": true, "license": "Apache-2.0" }, + "node_modules/google-closure-compiler-linux": { + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260211.0.0.tgz", + "integrity": "sha512-xfh8Shr4lv31jxc+XNedVMbiqLddbxgiaOK93YBrdXFe6g5akKoCJLbIPRi9E3Ul6OzNI1iXHzVJTc3p2KwgQg==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260211.0.0.tgz", + "integrity": "sha512-EIqVfIO5XcJZql6P+7kYKQz+WWGtEVYazvAjP50fXSWQ+OHpbBXRrKkDorcVuxN68ScixpNv5kbsIHtBy7kLiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/google-closure-compiler-macos": { - "version": "20260114.0.0", + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260211.0.0.tgz", + "integrity": "sha512-Kc9DQsfdaqyQyzuE+xJV8TtEr0fZzmn2ptXCrMtPOOBFzzk0lvA3k62GQYK4JVnL8NpspHcGmE1NExGqcPT+GA==", "cpu": [ "arm64" ], @@ -150,6 +185,21 @@ "darwin" ] }, + "node_modules/google-closure-compiler-windows": { + "version": "20260211.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260211.0.0.tgz", + "integrity": "sha512-L2mkdmelT9sEFnZS1yionNWbL7C5u7NrrEooSB4CRc+NUQXelfUa6LGLoYTZ6KVnnDgXmJZiwZ4/xUDpvG6hNA==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/lru-cache": { "version": "11.2.4", "dev": true, @@ -327,7 +377,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260114.0.0", + "google-closure-compiler": "^20260211.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 8494b7105ac..6862e73d2d4 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -122,7 +122,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260114.0.0", + "google-closure-compiler": "^20260211.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", From 615c852b1ddea953e2b9582f7e5d4070ae25ff8e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Feb 2026 12:03:25 -0800 Subject: [PATCH 21/23] fix: Fix test suite on non-macOS --- packages/blockly/tests/mocha/keyboard_movement_test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 8441f607c3f..d2b4986173c 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -397,7 +397,9 @@ suite('Keyboard-driven movement', function () { assert.deepEqual(newBounds, originalBounds); assert.equal( toastSpy.args[0][1]['message'], - 'Hold ⌥ Option and use arrow keys to move freely, then Enter to accept the position', + Blockly.utils.userAgent.MAC + ? 'Hold ⌥ Option and use arrow keys to move freely, then Enter to accept the position' + : 'Hold Ctrl and use arrow keys to move freely, then Enter to accept the position', ); toastSpy.restore(); }); From d2e2266aee46ccf356dfd53d61d7cfbc033c2bbb Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 20 Feb 2026 13:31:05 -0800 Subject: [PATCH 22/23] fix: Don't scroll in response to arrow keys while moving items --- packages/blockly/core/shortcut_items.ts | 32 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index aa1bdf73456..de4bd5c4c40 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -465,8 +465,12 @@ export function registerMovementShortcuts() { { name: 'move_left', preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), - callback: (workspace, e) => - workspace.getKeyboardMover().move(Direction.LEFT, e as KeyboardEvent), + callback: (workspace, e) => { + e.preventDefault(); + return workspace + .getKeyboardMover() + .move(Direction.LEFT, e as KeyboardEvent); + }, keyCodes: [ KeyCodes.LEFT, ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [ @@ -481,8 +485,12 @@ export function registerMovementShortcuts() { { name: 'move_right', preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), - callback: (workspace, e) => - workspace.getKeyboardMover().move(Direction.RIGHT, e as KeyboardEvent), + callback: (workspace, e) => { + e.preventDefault(); + return workspace + .getKeyboardMover() + .move(Direction.RIGHT, e as KeyboardEvent); + }, keyCodes: [ KeyCodes.RIGHT, ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [ @@ -497,8 +505,12 @@ export function registerMovementShortcuts() { { name: 'move_up', preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), - callback: (workspace, e) => - workspace.getKeyboardMover().move(Direction.UP, e as KeyboardEvent), + callback: (workspace, e) => { + e.preventDefault(); + return workspace + .getKeyboardMover() + .move(Direction.UP, e as KeyboardEvent); + }, keyCodes: [ KeyCodes.UP, ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ @@ -513,8 +525,12 @@ export function registerMovementShortcuts() { { name: 'move_down', preconditionFn: (workspace) => workspace.getKeyboardMover().isMoving(), - callback: (workspace, e) => - workspace.getKeyboardMover().move(Direction.DOWN, e as KeyboardEvent), + callback: (workspace, e) => { + e.preventDefault(); + return workspace + .getKeyboardMover() + .move(Direction.DOWN, e as KeyboardEvent); + }, keyCodes: [ KeyCodes.DOWN, ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [ From 20f32f60ee38cbe3b1b6ee2cecb53fe10899d0ac Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 20 Feb 2026 13:34:18 -0800 Subject: [PATCH 23/23] fix: Fix positioning of move indicator in RTL --- packages/blockly/core/keyboard_nav/keyboard_mover.ts | 5 ++++- packages/blockly/core/keyboard_nav/move_indicator.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index ab02cab0292..0ff623f44ec 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -262,7 +262,10 @@ export class KeyboardMover { const bounds = this.draggable?.getBoundingRectangle(); if (!bounds) return; - this.moveIndicator?.moveTo(bounds.right, bounds.top); + this.moveIndicator?.moveTo( + this.workspace.RTL ? bounds.left : bounds.right, + bounds.top, + ); } /** diff --git a/packages/blockly/core/keyboard_nav/move_indicator.ts b/packages/blockly/core/keyboard_nav/move_indicator.ts index c445233f56f..b1de2f76833 100644 --- a/packages/blockly/core/keyboard_nav/move_indicator.ts +++ b/packages/blockly/core/keyboard_nav/move_indicator.ts @@ -23,7 +23,7 @@ export class MoveIndicator { * * @param workspace The workspace the indicator should be displayed on. */ - constructor(workspace: WorkspaceSvg) { + constructor(private workspace: WorkspaceSvg) { this.svgRoot = dom.createSvgElement( Svg.G, {}, @@ -66,7 +66,10 @@ export class MoveIndicator { * @param y The location on the Y axis to move to. */ moveTo(x: number, y: number) { - this.svgRoot.setAttribute('transform', `translate(${x - 20}, ${y - 20})`); + this.svgRoot.setAttribute( + 'transform', + `translate(${x + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`, + ); } /**