diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index 96600ac4e8f..26bece29185 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -16,6 +16,7 @@ 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 {screenToWsCoordinates} from '../utils/svg_math.js'; export class Dragger implements IDragger { protected startLoc: Coordinate; @@ -46,17 +47,19 @@ export class Dragger implements IDragger { onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) { this.moveDraggable(e, totalDelta); + const pointerEvent = e instanceof PointerEvent ? e : null; + if (!pointerEvent) return; + + const coordinate = this.pointerToWorkspaceCoordinate(pointerEvent); // Must check `wouldDelete` before calling other hooks on drag targets // since we have documented that we would do so. if (isDeletable(this.draggable)) { this.draggable.setDeleteStyle( - this.wouldDeleteDraggable( - this.draggable.getRelativeToSurfaceXY(), - this.draggable, - ), + this.wouldDeleteDraggable(coordinate, this.draggable), ); } - this.updateDragTarget(this.draggable.getRelativeToSurfaceXY()); + + this.updateDragTarget(coordinate); } /** Updates the drag target under the pointer (if there is one). */ @@ -107,31 +110,34 @@ export class Dragger implements IDragger { /** Handles any drag cleanup. */ onDragEnd(e?: PointerEvent | KeyboardEvent) { const origGroup = eventUtils.getGroup(); - const dragTarget = this.draggable.workspace.getDragTarget( - this.draggable.getRelativeToSurfaceXY(), - ); + const pointerEvent = e instanceof PointerEvent ? e : null; + + if (!pointerEvent) { + // For keyboard events, we don't check for a drag target or delete area. Just commit the drag. + this.draggable.endDrag(e, DragDisposition.COMMIT); + if (isFocusableNode(this.draggable)) { + // Ensure focusable nodes end drag with focus and selection. + getFocusManager().focusNode(this.draggable); + } + return; + } + + const coordinate = this.pointerToWorkspaceCoordinate(pointerEvent); + const dragTarget = this.draggable.workspace.getDragTarget(coordinate); if (dragTarget) { this.dragTarget?.onDrop(this.draggable); } let reverted = false; - if ( - this.shouldReturnToStart( - this.draggable.getRelativeToSurfaceXY(), - this.draggable, - ) - ) { + if (this.shouldReturnToStart(coordinate, this.draggable)) { reverted = true; this.draggable.revertDrag(); } const wouldDelete = isDeletable(this.draggable) && - this.wouldDeleteDraggable( - this.draggable.getRelativeToSurfaceXY(), - this.draggable, - ); + this.wouldDeleteDraggable(coordinate, this.draggable); if (wouldDelete && isDeletable(this.draggable)) { this.draggable.endDrag(e, DragDisposition.DELETE); @@ -176,6 +182,17 @@ export class Dragger implements IDragger { return dragTarget.shouldPreventMove(rootDraggable); } + /** + * Returns the workspace coordinate for a pointer position, for delete-area + * hit testing. + */ + private pointerToWorkspaceCoordinate(e: PointerEvent): Coordinate { + return screenToWsCoordinates( + this.draggable.workspace, + new Coordinate(e.clientX, e.clientY), + ); + } + protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate { const result = new Coordinate( pixelCoord.x / this.draggable.workspace.scale, diff --git a/packages/blockly/tests/mocha/dragger_test.js b/packages/blockly/tests/mocha/dragger_test.js new file mode 100644 index 00000000000..0f69ec15867 --- /dev/null +++ b/packages/blockly/tests/mocha/dragger_test.js @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/index.js'; +import { + defineBasicBlockWithField, + defineStackBlock, +} from './test_helpers/block_definitions.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Dragger', function () { + /** + * @param {!Blockly.BlockSvg} block The block to measure. + * @returns {{x: number, y: number}} Viewport coordinates at the block center. + */ + function blockCenterClient(block) { + const boundingRect = block.getSvgRoot().getBoundingClientRect(); + return { + x: (boundingRect.left + boundingRect.right) / 2, + y: (boundingRect.top + boundingRect.bottom) / 2, + }; + } + + /** + * @param {!Blockly.BlockSvg} block The block to measure. + * @returns {{x: number, y: number}} Viewport coordinates at the block origin. + */ + function blockOriginClient(block) { + const screenCoords = Blockly.utils.svgMath.wsToScreenCoordinates( + block.workspace, + block.getRelativeToSurfaceXY(), + ); + return {x: screenCoords.x, y: screenCoords.y}; + } + + /** + * @param {!Blockly.utils.Rect} rect The rectangle to measure. + * @returns {{x: number, y: number}} Viewport coordinates at the rect center. + */ + function rectCenterClient(rect) { + return { + x: (rect.left + rect.right) / 2, + y: (rect.top + rect.bottom) / 2, + }; + } + + /** + * @param {number} clientX The viewport x coordinate. + * @param {number} clientY The viewport y coordinate. + * @param {string=} type The pointer event type. + * @returns {!PointerEvent} A synthetic pointer event at the given location. + */ + function pointerAt(clientX, clientY, type = 'pointermove') { + return new PointerEvent(type, {clientX, clientY}); + } + + function hasDeleteStyle(block) { + return block.getSvgRoot().classList.contains('blocklyDraggingDelete'); + } + + /** + * Simulates pressing on the block center and dragging to a viewport point. + * + * @param {!Blockly.BlockSvg} block The block to drag. + * @param {{x: number, y: number}} pointerEnd The viewport point to drag to. + * @returns {{dragger: !Blockly.dragging.Dragger, dragEvent: !PointerEvent}} + * The dragger and final pointer event from the simulated drag. + */ + function dragBlock(block, pointerEnd) { + const start = blockCenterClient(block); + const totalDelta = new Blockly.utils.Coordinate( + pointerEnd.x - start.x, + pointerEnd.y - start.y, + ); + + const dragger = new Blockly.dragging.Dragger(block); + const dragStartEvent = pointerAt(start.x, start.y, 'pointerdown'); + const dragEvent = pointerAt(pointerEnd.x, pointerEnd.y); + + dragger.onDragStart(dragStartEvent); + dragger.onDrag(dragEvent, totalDelta); + + return {dragger, dragEvent}; + } + + setup(function () { + sharedTestSetup.call(this); + defineBasicBlockWithField(); + defineStackBlock(); + const toolbox = document.getElementById('toolbox-categories'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox, trashcan: true}); + this.workspace.recordDragTargets(); + this.trashRect = this.workspace.trashcan.getClientRect(); + this.toolboxRect = this.workspace.toolbox.getClientRect(); + assert.isNotNull(this.trashRect); + assert.isNotNull(this.toolboxRect); + + this.block = this.workspace.newBlock('stack_block'); + this.block.initSvg(); + this.block.render(); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + [ + {name: 'trashcan', rectKey: 'trashRect'}, + {name: 'toolbox', rectKey: 'toolboxRect'}, + ].forEach(({name, rectKey}) => { + test(`applies delete styling and deletes when dragged to ${name}`, function () { + const deleteRect = this[rectKey]; + const {dragger, dragEvent} = dragBlock( + this.block, + rectCenterClient(deleteRect), + ); + + assert.isTrue( + deleteRect.contains(dragEvent.clientX, dragEvent.clientY), + `Expected cursor to be inside ${name} delete area`, + ); + assert.isTrue(hasDeleteStyle(this.block)); + + dragger.onDragEnd(dragEvent); + assert.isTrue(this.block.isDeadOrDying()); + }); + }); + + test('does not apply delete styling when only block origin overlaps delete area', function () { + const start = blockCenterClient(this.block); + const originBefore = blockOriginClient(this.block); + const deleteAreaRect = this.toolboxRect; + const desiredOrigin = { + x: deleteAreaRect.right - 5, + y: originBefore.y, + }; + const {dragger, dragEvent} = dragBlock(this.block, { + x: start.x + desiredOrigin.x - originBefore.x, + y: start.y + desiredOrigin.y - originBefore.y, + }); + + const originAfter = blockOriginClient(this.block); + assert.isTrue( + deleteAreaRect.contains(originAfter.x, originAfter.y), + 'Expected block origin to overlap delete area', + ); + assert.isFalse( + deleteAreaRect.contains(dragEvent.clientX, dragEvent.clientY), + 'Expected cursor to be outside delete area', + ); + assert.isFalse(hasDeleteStyle(this.block)); + + dragger.onDragEnd(dragEvent); + assert.isFalse(this.block.isDeadOrDying()); + }); +}); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 75b82509bff..62a332c44c1 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -295,6 +295,7 @@ import './event_block_create_test.js'; import './event_block_delete_test.js'; import './event_block_drag_test.js'; + import './dragger_test.js'; import './event_block_field_intermediate_change_test.js'; import './event_block_move_test.js'; import './event_bubble_open_test.js';