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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions packages/blockly/core/dragging/dragger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
162 changes: 162 additions & 0 deletions packages/blockly/tests/mocha/dragger_test.js
Original file line number Diff line number Diff line change
@@ -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());
});
});
1 change: 1 addition & 0 deletions packages/blockly/tests/mocha/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down