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
This commit is contained in:
Mike Harvey
2026-03-16 12:19:59 -04:00
committed by GitHub
parent 25968ffbdf
commit 3044298f99
4 changed files with 146 additions and 24 deletions
@@ -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;
}
}
/**
@@ -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';
+30 -19
View File
@@ -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',
@@ -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,
@@ -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);
@@ -407,6 +414,103 @@ suite('Keyboard-driven movement', function () {
testExemptedShortcutsAllowed();
});
suite('to disconnect blocks', function () {
setup(function () {
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.previousConnection.targetBlock());
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.previousConnection.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.isNull(this.block2.previousConnection.targetBlock());
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.previousConnection.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');