Skip to content
Open
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
54 changes: 29 additions & 25 deletions packages/blockly/core/dragging/dragger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,22 @@ 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;

// 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(pointerEvent, this.draggable),
);
}
this.updateDragTarget(this.draggable.getRelativeToSurfaceXY());
this.updateDragTarget(pointerEvent);
}

/** Updates the drag target under the pointer (if there is one). */
protected updateDragTarget(coordinate: Coordinate) {
const newDragTarget = this.draggable.workspace.getDragTarget(coordinate);
protected updateDragTarget(pointerEvent: PointerEvent) {
const newDragTarget = this.draggable.workspace.getDragTarget(pointerEvent);
if (this.dragTarget !== newDragTarget) {
this.dragTarget?.onDragExit(this.draggable);
newDragTarget?.onDragEnter(this.draggable);
Expand All @@ -88,10 +88,10 @@ export class Dragger implements IDragger {
* at the current location.
*/
protected wouldDeleteDraggable(
coordinate: Coordinate,
pointerEvent: PointerEvent,
rootDraggable: IDraggable & IDeletable,
) {
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
const dragTarget = this.draggable.workspace.getDragTarget(pointerEvent);
if (!dragTarget) return false;

const componentManager = this.draggable.workspace.getComponentManager();
Expand All @@ -107,31 +107,35 @@ 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;
}

let wouldDelete = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this moved back here and made a let? It seems like it's still only assigned in one place and read right after that down below?


const dragTarget = this.draggable.workspace.getDragTarget(pointerEvent);

if (dragTarget) {
this.dragTarget?.onDrop(this.draggable);
}

let reverted = false;
if (
this.shouldReturnToStart(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
)
) {
if (this.shouldReturnToStart(pointerEvent, this.draggable)) {
reverted = true;
this.draggable.revertDrag();
}

const wouldDelete =
wouldDelete =
isDeletable(this.draggable) &&
this.wouldDeleteDraggable(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
);
this.wouldDeleteDraggable(pointerEvent, this.draggable);

if (wouldDelete && isDeletable(this.draggable)) {
this.draggable.endDrag(e, DragDisposition.DELETE);
Expand Down Expand Up @@ -168,10 +172,10 @@ export class Dragger implements IDragger {
* at the end of the drag.
*/
protected shouldReturnToStart(
coordinate: Coordinate,
pointerEvent: PointerEvent,
rootDraggable: IDraggable,
) {
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
const dragTarget = this.draggable.workspace.getDragTarget(pointerEvent);
if (!dragTarget) return false;
return dragTarget.shouldPreventMove(rootDraggable);
}
Expand Down
10 changes: 3 additions & 7 deletions packages/blockly/core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1480,17 +1480,13 @@ export class WorkspaceSvg
/**
* Returns the drag target the pointer event is over.
*
* @param e Pointer move event or a workspace coordinate.
* @param e Pointer move event.
* @returns Null if not over a drag target, or the drag target the event is
* over.
*/
getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here especially, and kinda throughout, I think this really should have always taken a coordinate; I'd suggest leaving it as-is. Also this is technically breaking. In general I think only the actual event listener should handle events, and any methods called by it or its callees should take specific/abstracted arguments, rather than handing events around all over the place and picking one or two properties out of them.

const coordinate =
e instanceof Coordinate
? svgMath.wsToScreenCoordinates(this, e)
: new Coordinate(e.clientX, e.clientY);
getDragTarget(e: PointerEvent): IDragTarget | null {
for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) {
if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) {
if (targetArea.clientRect.contains(e.clientX, e.clientY)) {
return targetArea.component;
}
}
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 bounds = block.getSvgRoot().getBoundingClientRect();
return {
x: (bounds.left + bounds.right) / 2,
y: (bounds.top + bounds.bottom) / 2,
Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only maths if it's at (0,0) right? and same for rectCenterClient below

};
}

/**
* @param {!Blockly.BlockSvg} block The block to measure.
* @returns {{x: number, y: number}} Viewport coordinates at the block origin.
*/
function blockOriginClient(block) {
const screen = Blockly.utils.svgMath.wsToScreenCoordinates(
block.workspace,
block.getRelativeToSurfaceXY(),
);
return {x: screen.x, y: screen.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
Loading