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/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 83af5188e99..69b9312de8a 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. */ @@ -1854,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. */ diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 99112d790fb..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'; @@ -125,7 +127,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'; @@ -137,6 +142,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 +506,8 @@ export { BlockFlyoutInflater, ButtonFlyoutInflater, CodeGenerator, + Direction, + DragDisposition, Field, FieldCheckbox, FieldCheckboxConfig, @@ -584,6 +592,7 @@ export { ImageProperties, Input, InsertionMarkerPreviewer, + KeyboardMover, KeyboardNavigationController, LabelFlyoutInflater, LayerManager, @@ -595,6 +604,7 @@ export { MenuItem, MenuOption, MetricsManager, + MoveIndicator, Msg, Names, Options, @@ -626,6 +636,7 @@ export { icons, inject, inputs, + isBoundedElement, isCopyable, isDeletable, isDraggable, diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 742d300adf1..422c2b9ea9b 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -8,7 +8,9 @@ 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'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; @@ -29,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; @@ -274,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). @@ -617,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; @@ -664,8 +695,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/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/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 0fb6d531eea..bf9172dc60f 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'; @@ -14,17 +14,20 @@ 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 {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 {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'; -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 { @@ -38,6 +41,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; @@ -58,11 +71,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 = ''; @@ -73,10 +89,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() && @@ -91,12 +103,7 @@ export class BlockDragStrategy implements IDragStrategy { * Handles any setup for starting the drag, including disconnecting the block * from any parent blocks. */ - startDrag(e?: PointerEvent): void { - if (this.block.isShadow()) { - this.startDraggingShadow(e); - return; - } - + startDrag(e?: PointerEvent | KeyboardEvent): IDraggable { this.dragging = true; this.fireDragStartEvent(); @@ -125,6 +132,34 @@ export class BlockDragStrategy implements IDragStrategy { this.getVisibleBubbles(this.block).forEach((bubble) => { 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(...this.getAllConnections(topBlock)); + } + + // 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; } /** @@ -159,24 +194,10 @@ 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) { - return !!e && (e.altKey || e.ctrlKey || e.metaKey); - } - - /** Starts a drag on a shadow, recording the drag offset. */ - private startDraggingShadow(e?: PointerEvent) { - 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); + protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) { + return e instanceof PointerEvent + ? e.altKey || e.ctrlKey || e.metaKey + : !!this.block.previousConnection; } /** @@ -246,25 +267,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) { @@ -299,9 +351,10 @@ export class BlockDragStrategy implements IDragStrategy { neighbour, neighbour.targetBlock()!, ); - return; + } else { + this.connectionPreviewer?.previewConnection(local, neighbour); } - this.connectionPreviewer?.previewConnection(local, neighbour); + return neighbour; } /** @@ -333,6 +386,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); @@ -356,9 +412,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) { @@ -378,6 +451,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; @@ -402,11 +477,14 @@ export class BlockDragStrategy implements IDragStrategy { * Cleans up any state at the end of the drag. Applies any pending * connections. */ - endDrag(e?: PointerEvent): void { - if (this.block.isShadow()) { - this.block.getParent()?.endDrag(e); - return; + endDrag( + _e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void { + if (disposition === DragDisposition.DELETE) { + blockAnimation.disposeUiEffect(this.block); } + this.originalEventGroup = eventUtils.getGroup(); this.fireDragEndEvent(); @@ -440,6 +518,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. */ @@ -477,11 +557,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; @@ -520,4 +595,172 @@ 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) && + !potential.targetBlock()?.isInsertionMarker() + ) { + 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; + } + + /** + * 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[]; + } } 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..1193806b7c6 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -4,20 +4,19 @@ * 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'; -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,11 +31,11 @@ export class Dragger implements IDragger { } /** Handles any drag startup. */ - onDragStart(e: PointerEvent) { + onDragStart(e?: PointerEvent | KeyboardEvent) { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } - this.draggable.startDrag(e); + this.draggable = this.draggable.startDrag(e); } /** @@ -45,27 +44,30 @@ 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)); + if (isDeletable(this.draggable)) { + this.draggable.setDeleteStyle( + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ), + ); } - 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); - const root = this.getRoot(this.draggable); + protected updateDragTarget(coordinate: Coordinate) { + const newDragTarget = this.workspace.getDragTarget(coordinate); 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; } @@ -73,7 +75,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 +89,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 +106,46 @@ 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 root = this.getRoot(this.draggable); + const dragTarget = this.workspace.getDragTarget( + this.draggable.getRelativeToSurfaceXY(), + ); if (dragTarget) { - this.dragTarget?.onDrop(root); + this.dragTarget?.onDrop(this.draggable); } - if (this.shouldReturnToStart(e, root)) { + let reverted = false; + if ( + this.shouldReturnToStart( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ) + ) { + reverted = true; this.draggable.revertDrag(); } - const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, 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); + const wouldDelete = + isDeletable(this.draggable) && + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ); - 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, + reverted ? DragDisposition.REVERT : DragDisposition.COMMIT, + ); } eventUtils.setGroup(false); @@ -139,18 +156,23 @@ 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; + /** Handles a drag being reverted. */ + onDragRevert() { + this.draggable.revertDrag(); + if (isFocusableNode(this.draggable)) { + getFocusManager().focusNode(this.draggable); + } } /** * 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/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/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' + ); +} diff --git a/packages/blockly/core/interfaces/i_draggable.ts b/packages/blockly/core/interfaces/i_draggable.ts index 9130381163f..28b59bf2c7b 100644 --- a/packages/blockly/core/interfaces/i_draggable.ts +++ b/packages/blockly/core/interfaces/i_draggable.ts @@ -4,15 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {Coordinate} from '../utils/coordinate'; +import type {Coordinate} from '../utils/coordinate'; + +export enum DragDisposition { + COMMIT = 1, + DELETE = 2, + REVERT = 3, +} /** * 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 +32,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 +44,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/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts new file mode 100644 index 00000000000..0ff623f44ec --- /dev/null +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -0,0 +1,326 @@ +/** + * @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'; +import {MoveIndicator} from './move_indicator.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; + + /** + * 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(); + }; + + /** + * 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(); + this.moveIndicator = new MoveIndicator(this.workspace); + this.repositionMoveIndicator(); + + 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.totalPixelDelta()); + + this.updateTotalDelta(); + this.scrollCurrentElementIntoView(); + this.repositionMoveIndicator(); + + 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.totalPixelDelta()); + + 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.totalPixelDelta()); + + 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; + } + + /** + * 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( + this.workspace.RTL ? bounds.left : bounds.right, + bounds.top, + ); + } + + /** + * 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.moveIndicator?.dispose(); + this.moveIndicator = undefined; + this.draggable = undefined; + this.dragger = undefined; + this.startLocation = undefined; + 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. + */ + 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/keyboard_nav/move_indicator.ts b/packages/blockly/core/keyboard_nav/move_indicator.ts new file mode 100644 index 00000000000..b1de2f76833 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/move_indicator.ts @@ -0,0 +1,81 @@ +/** + * @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(private 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 + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`, + ); + } + + /** + * Disposes of this move indicator. + */ + dispose() { + dom.removeNode(this.svgRoot); + } +} diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index f8c95500770..de4bd5c4c40 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,159 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Registers keyboard shortcuts for keyboard-driven movement of workspace + *elements. + */ +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) => { + e.preventDefault(); + return 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) => { + e.preventDefault(); + return 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) => { + e.preventDefault(); + return 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) => { + e.preventDefault(); + return 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 +563,7 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); + registerMovementShortcuts(); } registerDefaultShortcuts(); diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index de158c6d426..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. */ @@ -1427,13 +1434,17 @@ 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): IDragTarget | null { + getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null { + const coordinate = + 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(e.clientX, e.clientY)) { + if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) { return targetArea.component; } } @@ -2940,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; + } } /** 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} */ 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", 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..d2b4986173c --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -0,0 +1,818 @@ +/** + * @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 {!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 {!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); + 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 {!Promise<{parentId: string | null, parentIndex: number | null, nextId: string | null, valueId: string | null}>} 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 {!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} + * + * 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 {!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 () { + // 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'], + 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(); + }); + + 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..c29dab7e709 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/fixtures.js @@ -0,0 +1,528 @@ +/** + * @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}; 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, +};