Skip to content

Commit 3044298

Browse files
authored
feat: Move mode for stacks of blocks (#9630)
* feat: Move mode for stacks of blocks * lint; add tests * push to remote in order to switch devices (tests still failing) * fix tests * code review test updates
1 parent 25968ff commit 3044298

4 files changed

Lines changed: 146 additions & 24 deletions

File tree

packages/blockly/core/dragging/block_drag_strategy.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,22 @@ export class BlockDragStrategy implements IDragStrategy {
256256
/**
257257
* Get whether the drag should act on a single block or a block stack.
258258
*
259-
* @param e The instigating pointer event, if any.
259+
* @param e The instigating pointer or keyboard event, if any.
260260
* @returns True if just the initial block should be dragged out, false
261261
* if all following blocks should also be dragged.
262262
*/
263263
protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) {
264-
return e instanceof PointerEvent
265-
? e.ctrlKey || e.metaKey
266-
: !!this.block.previousConnection;
264+
if (e instanceof PointerEvent) {
265+
// For pointer events, we drag the whole stack unless a modifier key
266+
// was also pressed.
267+
return e.ctrlKey || e.metaKey;
268+
} else if (e instanceof KeyboardEvent) {
269+
// For keyboard events, we drag the single focused block, unless the
270+
// shift key is pressed or the block has no previous connection.
271+
return !(e.shiftKey || !this.block.previousConnection);
272+
} else {
273+
return false;
274+
}
267275
}
268276

269277
/**

packages/blockly/core/keyboard_nav/keyboard_mover.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* Copyright 2026 Raspberry Pi Foundation
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
76
import type {IDraggable} from '../interfaces/i_draggable.js';
87
import type {IDragger} from '../interfaces/i_dragger.js';
98
import * as registry from '../registry.js';

packages/blockly/core/shortcut_items.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export enum names {
4040
MENU = 'menu',
4141
FOCUS_WORKSPACE = 'focus_workspace',
4242
START_MOVE = 'start_move',
43+
START_MOVE_STACK = 'start_move_stack',
4344
FINISH_MOVE = 'finish_move',
4445
ABORT_MOVE = 'abort_move',
4546
MOVE_UP = 'move_up',
@@ -397,27 +398,37 @@ export function registerMovementShortcuts() {
397398
return workspace.getCursor().getSourceBlock() ?? undefined;
398399
};
399400

401+
const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [
402+
KeyCodes.SHIFT,
403+
]);
404+
405+
const startMoveShortcut: KeyboardShortcut = {
406+
name: names.START_MOVE,
407+
preconditionFn: (workspace) => {
408+
const startDraggable = getCurrentDraggable(workspace);
409+
return !!startDraggable && KeyboardMover.mover.canMove(startDraggable);
410+
},
411+
callback: (workspace, e) => {
412+
keyboardNavigationController.setIsActive(true);
413+
const startDraggable = getCurrentDraggable(workspace);
414+
// Focus the root draggable in case one of its children
415+
// was focused when the move was triggered.
416+
if (startDraggable) {
417+
getFocusManager().focusNode(startDraggable);
418+
}
419+
return (
420+
!!startDraggable &&
421+
KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent)
422+
);
423+
},
424+
keyCodes: [KeyCodes.M],
425+
};
400426
const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [
427+
startMoveShortcut,
401428
{
402-
name: names.START_MOVE,
403-
preconditionFn: (workspace) => {
404-
const startDraggable = getCurrentDraggable(workspace);
405-
return !!startDraggable && KeyboardMover.mover.canMove(startDraggable);
406-
},
407-
callback: (workspace, e) => {
408-
keyboardNavigationController.setIsActive(true);
409-
const startDraggable = getCurrentDraggable(workspace);
410-
// Focus the root draggable in case one of its children
411-
// was focused when the move was triggered.
412-
if (startDraggable) {
413-
getFocusManager().focusNode(startDraggable);
414-
}
415-
return (
416-
!!startDraggable &&
417-
KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent)
418-
);
419-
},
420-
keyCodes: [KeyCodes.M],
429+
...startMoveShortcut,
430+
name: names.START_MOVE_STACK,
431+
keyCodes: [shiftM],
421432
},
422433
{
423434
name: names.FINISH_MOVE,

packages/blockly/tests/mocha/keyboard_movement_test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ suite('Keyboard-driven movement', function () {
3535
workspace.getInjectionDiv().dispatchEvent(event);
3636
}
3737

38+
function startMoveStack(workspace) {
39+
const event = createKeyDownEvent(Blockly.utils.KeyCodes.M, [
40+
Blockly.utils.KeyCodes.SHIFT,
41+
]);
42+
workspace.getInjectionDiv().dispatchEvent(event);
43+
}
44+
3845
function moveUp(workspace, modifiers) {
3946
const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers);
4047
workspace.getInjectionDiv().dispatchEvent(event);
@@ -407,6 +414,103 @@ suite('Keyboard-driven movement', function () {
407414
testExemptedShortcutsAllowed();
408415
});
409416

417+
suite('to disconnect blocks', function () {
418+
setup(function () {
419+
this.block1 = this.workspace.newBlock('draw_emoji');
420+
this.block1.initSvg();
421+
this.block1.render();
422+
423+
this.block2 = this.workspace.newBlock('draw_emoji');
424+
this.block2.initSvg();
425+
this.block2.render();
426+
this.block1.nextConnection.connect(this.block2.previousConnection);
427+
428+
this.block3 = this.workspace.newBlock('draw_emoji');
429+
this.block3.initSvg();
430+
this.block3.render();
431+
this.block2.nextConnection.connect(this.block3.previousConnection);
432+
});
433+
434+
test('from top block - Detaches single block', function () {
435+
Blockly.getFocusManager().focusNode(this.block1);
436+
startMove(this.workspace);
437+
assert.isNull(this.block1.nextConnection.targetBlock());
438+
assert.equal(this.block1.isDragging(), true);
439+
assert.equal(this.block2.isDragging(), false);
440+
assert.equal(this.block3.isDragging(), false);
441+
cancelMove(this.workspace);
442+
});
443+
444+
test('from middle block - Detaches single block', function () {
445+
Blockly.getFocusManager().focusNode(this.block2);
446+
startMove(this.workspace);
447+
assert.isNull(this.block2.previousConnection.targetBlock());
448+
assert.isNull(this.block2.nextConnection.targetBlock());
449+
assert.equal(this.block1.isDragging(), false);
450+
assert.equal(this.block2.isDragging(), true);
451+
assert.equal(this.block3.isDragging(), false);
452+
cancelMove(this.workspace);
453+
});
454+
455+
test('from bottom block - Detaches single block', function () {
456+
Blockly.getFocusManager().focusNode(this.block3);
457+
startMove(this.workspace);
458+
assert.isNull(this.block3.previousConnection.targetBlock());
459+
assert.equal(this.block1.isDragging(), false);
460+
assert.equal(this.block2.isDragging(), false);
461+
assert.equal(this.block3.isDragging(), true);
462+
cancelMove(this.workspace);
463+
});
464+
465+
test('from top block - Detaches entire three-block stack', function () {
466+
Blockly.getFocusManager().focusNode(this.block1);
467+
startMoveStack(this.workspace);
468+
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
469+
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
470+
assert.equal(this.block1.isDragging(), true);
471+
assert.equal(this.block2.isDragging(), true);
472+
assert.equal(this.block3.isDragging(), true);
473+
cancelMove(this.workspace);
474+
});
475+
476+
test('from middle block - Detaches two-block stack from middle down', function () {
477+
Blockly.getFocusManager().focusNode(this.block2);
478+
startMoveStack(this.workspace);
479+
assert.isNull(this.block2.previousConnection.targetBlock());
480+
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
481+
assert.equal(this.block1.isDragging(), false);
482+
assert.equal(this.block2.isDragging(), true);
483+
assert.equal(this.block3.isDragging(), true);
484+
cancelMove(this.workspace);
485+
});
486+
487+
test('from bottom block - Detaches single-block stack from bottom', function () {
488+
Blockly.getFocusManager().focusNode(this.block3);
489+
startMoveStack(this.workspace);
490+
assert.isNull(this.block3.previousConnection.targetBlock());
491+
assert.equal(this.block1.isDragging(), false);
492+
assert.equal(this.block2.isDragging(), false);
493+
assert.equal(this.block3.isDragging(), true);
494+
cancelMove(this.workspace);
495+
});
496+
497+
test('Cancel move restores connections', function () {
498+
Blockly.getFocusManager().focusNode(this.block2);
499+
startMove(this.workspace);
500+
cancelMove(this.workspace);
501+
// Original stack restored
502+
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
503+
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
504+
505+
Blockly.getFocusManager().focusNode(this.block2);
506+
startMoveStack(this.workspace);
507+
cancelMove(this.workspace);
508+
// Original stack restored
509+
assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2);
510+
assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3);
511+
});
512+
});
513+
410514
suite('of blocks', function () {
411515
setup(function () {
412516
this.element = this.workspace.newBlock('logic_boolean');

0 commit comments

Comments
 (0)