diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index cabf7beae4c..39dff646ec2 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -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; + } } /** diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index f3c9ecee09f..ea2aefc36f8 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -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'; diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index e2ec187470e..de13f0788c1 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -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, diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index b4ad43d71df..9d6ac0cdddf 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -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');