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
16 changes: 12 additions & 4 deletions packages/blockly/core/dragging/block_drag_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,22 @@ export class BlockDragStrategy implements IDragStrategy {
/**
* Get whether the drag should act on a single block or a block stack.
*
* @param e The instigating pointer event, if any.
* @param e The instigating pointer or keyboard event, if any.
* @returns True if just the initial block should be dragged out, false
* if all following blocks should also be dragged.
*/
protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) {
return e instanceof PointerEvent
? e.ctrlKey || e.metaKey
: !!this.block.previousConnection;
if (e instanceof PointerEvent) {
// For pointer events, we drag the whole stack unless a modifier key
// was also pressed.
return e.ctrlKey || e.metaKey;
} else if (e instanceof KeyboardEvent) {
// For keyboard events, we drag the single focused block, unless the
// shift key is pressed or the block has no previous connection.
return !(e.shiftKey || !this.block.previousConnection);
} else {
return false;
}
}

/**
Expand Down
1 change: 0 additions & 1 deletion packages/blockly/core/keyboard_nav/keyboard_mover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/

import type {IDraggable} from '../interfaces/i_draggable.js';
import type {IDragger} from '../interfaces/i_dragger.js';
import * as registry from '../registry.js';
Expand Down
49 changes: 30 additions & 19 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export enum names {
MENU = 'menu',
FOCUS_WORKSPACE = 'focus_workspace',
START_MOVE = 'start_move',
START_MOVE_STACK = 'start_move_stack',
FINISH_MOVE = 'finish_move',
ABORT_MOVE = 'abort_move',
MOVE_UP = 'move_up',
Expand Down Expand Up @@ -397,27 +398,37 @@ export function registerMovementShortcuts() {
return workspace.getCursor().getSourceBlock() ?? undefined;
};

const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [
KeyCodes.SHIFT,
]);

const startMoveShortcut: KeyboardShortcut = {
name: names.START_MOVE,
preconditionFn: (workspace) => {
const startDraggable = getCurrentDraggable(workspace);
return !!startDraggable && KeyboardMover.mover.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 &&
KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent)
);
},
keyCodes: [KeyCodes.M],
};
const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [
startMoveShortcut,
{
name: names.START_MOVE,
preconditionFn: (workspace) => {
const startDraggable = getCurrentDraggable(workspace);
return !!startDraggable && KeyboardMover.mover.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 &&
KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent)
);
},
keyCodes: [KeyCodes.M],
...startMoveShortcut,
name: names.START_MOVE_STACK,
keyCodes: [shiftM],
},
{
name: names.FINISH_MOVE,
Expand Down
104 changes: 104 additions & 0 deletions packages/blockly/tests/mocha/keyboard_movement_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ suite('Keyboard-driven movement', function () {
workspace.getInjectionDiv().dispatchEvent(event);
}

function startMoveStack(workspace) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.M, [
Blockly.utils.KeyCodes.SHIFT,
]);
workspace.getInjectionDiv().dispatchEvent(event);
}

function moveUp(workspace, modifiers) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers);
workspace.getInjectionDiv().dispatchEvent(event);
Expand Down Expand Up @@ -407,6 +414,103 @@ suite('Keyboard-driven movement', function () {
testExemptedShortcutsAllowed();
});

suite('to disconnect blocks', function () {
setup(function () {
// Clear workspace and build 3-block stack.
this.workspace.clear();
this.block1 = this.workspace.newBlock('draw_emoji');
this.block1.initSvg();
this.block1.render();

this.block2 = this.workspace.newBlock('draw_emoji');
this.block2.initSvg();
this.block2.render();
this.block1.nextConnection.connect(this.block2.previousConnection);

this.block3 = this.workspace.newBlock('draw_emoji');
this.block3.initSvg();
this.block3.render();
this.block2.nextConnection.connect(this.block3.previousConnection);
});

test('from top block - Detaches single block', function () {
Blockly.getFocusManager().focusNode(this.block1);
startMove(this.workspace);
assert.isNull(this.block1.nextConnection.targetBlock());
assert.equal(this.block1.isDragging(), true);
assert.equal(this.block2.isDragging(), false);
assert.equal(this.block3.isDragging(), false);
cancelMove(this.workspace);
});

test('from middle block - Detaches single block', function () {
Blockly.getFocusManager().focusNode(this.block2);
startMove(this.workspace);
assert.isNull(this.block2.nextConnection.targetBlock());
assert.equal(this.block1.isDragging(), false);
assert.equal(this.block2.isDragging(), true);
assert.equal(this.block3.isDragging(), false);
cancelMove(this.workspace);
});

test('from bottom block - Detaches single block', function () {
Blockly.getFocusManager().focusNode(this.block3);
startMove(this.workspace);
assert.isNull(this.block3.nextConnection.targetBlock());
assert.equal(this.block1.isDragging(), false);
assert.equal(this.block2.isDragging(), false);
assert.equal(this.block3.isDragging(), true);
cancelMove(this.workspace);
});

test('from top block - Detaches entire three-block stack', function () {
Blockly.getFocusManager().focusNode(this.block1);
startMoveStack(this.workspace);
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
assert.equal(this.block1.isDragging(), true);
assert.equal(this.block2.isDragging(), true);
assert.equal(this.block3.isDragging(), true);
cancelMove(this.workspace);
});

test('from middle block - Detaches two-block stack from middle down', function () {
Blockly.getFocusManager().focusNode(this.block2);
startMoveStack(this.workspace);
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
assert.equal(this.block1.isDragging(), false);
assert.equal(this.block2.isDragging(), true);
assert.equal(this.block3.isDragging(), true);
cancelMove(this.workspace);
});

test('from bottom block - Detaches single-block stack from bottom', function () {
Blockly.getFocusManager().focusNode(this.block3);
startMoveStack(this.workspace);
assert.isNull(this.block3.nextConnection.targetBlock());
assert.equal(this.block1.isDragging(), false);
assert.equal(this.block2.isDragging(), false);
assert.equal(this.block3.isDragging(), true);
cancelMove(this.workspace);
});

test('Cancel move restores connections', function () {
Blockly.getFocusManager().focusNode(this.block2);
startMove(this.workspace);
cancelMove(this.workspace);
// Original stack restored
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);

Blockly.getFocusManager().focusNode(this.block2);
startMoveStack(this.workspace);
cancelMove(this.workspace);
// Original stack restored
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
});
});

suite('of blocks', function () {
setup(function () {
this.element = this.workspace.newBlock('logic_boolean');
Expand Down
Loading