From e4356f47139d353558c71405ee29aca0f750611f Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:31:20 -0400 Subject: [PATCH 1/4] feat: Move mode for stacks of blocks --- .../blockly/core/dragging/block_drag_strategy.ts | 10 +++++++--- packages/blockly/core/shortcut_items.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index cabf7beae4c..d18dce44f1b 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -261,9 +261,13 @@ export class BlockDragStrategy implements IDragStrategy { * 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) { + return e.ctrlKey || e.metaKey; + } else if (e instanceof KeyboardEvent) { + return !e.shiftKey; + } else { + return !!this.block.previousConnection; + } } /** diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index e2ec187470e..e6c504723e8 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,7 +398,11 @@ export function registerMovementShortcuts() { return workspace.getCursor().getSourceBlock() ?? undefined; }; - const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [ + KeyCodes.SHIFT, + ]); + + const startMoveShortcut: KeyboardShortcut = { name: names.START_MOVE, preconditionFn: (workspace) => { @@ -418,6 +423,13 @@ export function registerMovementShortcuts() { ); }, keyCodes: [KeyCodes.M], + }; + const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + startMoveShortcut, + { + ...startMoveShortcut, + name: names.START_MOVE_STACK, + keyCodes: [shiftM], }, { name: names.FINISH_MOVE, From 172952ee1d42d4646d24cd0b9f9783926acb078e Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:53:25 -0400 Subject: [PATCH 2/4] lint; add tests --- .../core/dragging/block_drag_strategy.ts | 2 +- .../core/keyboard_nav/keyboard_mover.ts | 5 + packages/blockly/core/shortcut_items.ts | 43 ++++--- .../tests/mocha/keyboard_movement_test.js | 107 ++++++++++++++++++ 4 files changed, 134 insertions(+), 23 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index d18dce44f1b..42b3edc647d 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -267,7 +267,7 @@ export class BlockDragStrategy implements IDragStrategy { return !e.shiftKey; } else { return !!this.block.previousConnection; - } + } } /** diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index f3c9ecee09f..9e591d87594 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {BlockSvg} from '../blockly.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IDragger} from '../interfaces/i_dragger.js'; import * as registry from '../registry.js'; @@ -169,6 +170,10 @@ export class KeyboardMover { this.moveIndicator = new MoveIndicator(this.draggable.workspace); this.repositionMoveIndicator(); + console.log( + (this.draggable as BlockSvg).previousConnection.targetBlock()?.id, + ); + console.log((this.draggable as BlockSvg).nextConnection.targetBlock()?.id); return true; } diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index e6c504723e8..de13f0788c1 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -402,28 +402,27 @@ export function registerMovementShortcuts() { 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 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, { diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index b4ad43d71df..2e34b4a1520 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,106 @@ 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('Top block - M detaches single block', function () { + startMove(this.block1, false); + + // Only block1 should be detached; block2/3 remain connected. + assert.isNull(this.block1.previousConnection.targetConnection); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual( + this.block2.previousConnection.targetBlock(), + this.block1, + ); + }); + + test('Top block - Shift+M detaches full stack', function () { + startMoveStack(this.block1, true); + + // All three blocks should be treated as moving together. + assert.isNull(this.block1.previousConnection.targetConnection); + assert.isNull(this.block2.previousConnection.targetConnection); + assert.isNull(this.block3.previousConnection.targetConnection); + }); + + test('Middle block - M detaches single block', function () { + startMove(this.block2, false); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block3); + assert.isNull(this.block2.previousConnection.targetConnection); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + }); + + test('Middle block - Shift+M detaches stack from middle down', function () { + startMoveStack(this.block2, true); + // block2 and block3 detached; block1 stays. + assert.strictEqual(this.block1.nextConnection.targetBlock(), null); + assert.isNull(this.block2.previousConnection.targetConnection); + assert.isNull(this.block3.previousConnection.targetConnection); + }); + + test('Bottom block - M detaches single block', function () { + startMove(this.block3, false); + assert.strictEqual(this.block2.nextConnection.targetBlock(), null); + assert.isNull(this.block3.previousConnection.targetConnection); + }); + + test('Bottom block - Shift+M detaches full stack from bottom', function () { + startMoveStack(this.block3, true); + // Only bottom block is disconnected, behaves same as M + assert.strictEqual(this.block2.nextConnection.targetBlock(), null); + assert.isNull(this.block3.previousConnection.targetConnection); + }); + + test('Cancel move restores connections', function () { + startMove(this.block2, true); + cancelMove(this.block2); + + // Original stack restored + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual( + this.block2.previousConnection.targetBlock(), + this.block1, + ); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + assert.strictEqual( + this.block3.previousConnection.targetBlock(), + this.block2, + ); + + startMoveStack(this.block2, true); + cancelMove(this.block2); + // Original stack restored + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual( + this.block2.previousConnection.targetBlock(), + this.block1, + ); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + assert.strictEqual( + this.block3.previousConnection.targetBlock(), + this.block2, + ); + }); + }); + suite('of blocks', function () { setup(function () { this.element = this.workspace.newBlock('logic_boolean'); From d8bd03ea90fc561c7b2959a9dd87436c9d2487ff Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:44:27 -0400 Subject: [PATCH 3/4] push to remote in order to switch devices (tests still failing) --- .../tests/mocha/keyboard_movement_test.js | 68 ++++++++----------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 2e34b4a1520..ce21b93daba 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -431,86 +431,72 @@ suite('Keyboard-driven movement', function () { this.block3.initSvg(); this.block3.render(); this.block2.nextConnection.connect(this.block3.previousConnection); + console.log(Blockly.serialization.blocks.save(this.workspace)); }); - test('Top block - M detaches single block', function () { + test('Top block - Detach single block', function () { startMove(this.block1, false); // Only block1 should be detached; block2/3 remain connected. - assert.isNull(this.block1.previousConnection.targetConnection); - assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - assert.strictEqual( - this.block2.previousConnection.targetBlock(), - this.block1, - ); + assert.isNull(this.block1.nextConnection.targetBlock()); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + cancelMove(this.block1); }); - test('Top block - Shift+M detaches full stack', function () { + test('Top block - Detach three-block stack', function () { startMoveStack(this.block1, true); // All three blocks should be treated as moving together. - assert.isNull(this.block1.previousConnection.targetConnection); - assert.isNull(this.block2.previousConnection.targetConnection); - assert.isNull(this.block3.previousConnection.targetConnection); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + cancelMove(this.block1); }); - test('Middle block - M detaches single block', function () { + test('Middle block - Detach single block', function () { startMove(this.block2, false); + assert.isNull(this.block2.previousConnection.targetBlock()); + assert.isNull(this.block2.nextConnection.targetBlock()); assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block3); - assert.isNull(this.block2.previousConnection.targetConnection); - assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + cancelMove(this.block2); }); - test('Middle block - Shift+M detaches stack from middle down', function () { + test('Middle block - Detaches two-block stack from middle down', function () { startMoveStack(this.block2, true); // block2 and block3 detached; block1 stays. + assert.isNull(this.block2.previousConnection.targetBlock()); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); assert.strictEqual(this.block1.nextConnection.targetBlock(), null); - assert.isNull(this.block2.previousConnection.targetConnection); - assert.isNull(this.block3.previousConnection.targetConnection); + cancelMove(this.block2); }); - test('Bottom block - M detaches single block', function () { + test('Bottom block - Detach single block', function () { startMove(this.block3, false); - assert.strictEqual(this.block2.nextConnection.targetBlock(), null); - assert.isNull(this.block3.previousConnection.targetConnection); + // Only block3 should be detached; block1/2 remain connected. + assert.isNull(this.block3.previousConnection.targetBlock()); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + cancelMove(this.block3); }); - test('Bottom block - Shift+M detaches full stack from bottom', function () { + test('Bottom block - Detaches single block stack from bottom', function () { startMoveStack(this.block3, true); - // Only bottom block is disconnected, behaves same as M - assert.strictEqual(this.block2.nextConnection.targetBlock(), null); - assert.isNull(this.block3.previousConnection.targetConnection); + // Only block3 is disconnected, behaves same as single block move + assert.isNull(this.block3.previousConnection.targetBlock()); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + cancelMove(this.block3); }); test('Cancel move restores connections', function () { startMove(this.block2, true); cancelMove(this.block2); - // Original stack restored assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - assert.strictEqual( - this.block2.previousConnection.targetBlock(), - this.block1, - ); assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); - assert.strictEqual( - this.block3.previousConnection.targetBlock(), - this.block2, - ); startMoveStack(this.block2, true); cancelMove(this.block2); // Original stack restored assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - assert.strictEqual( - this.block2.previousConnection.targetBlock(), - this.block1, - ); assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); - assert.strictEqual( - this.block3.previousConnection.targetBlock(), - this.block2, - ); }); }); From 958a6ba3000bc14b6d4bc6e2e4953f489cfd58a4 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:06:13 -0400 Subject: [PATCH 4/4] fix tests --- .../core/dragging/block_drag_strategy.ts | 10 +- .../core/keyboard_nav/keyboard_mover.ts | 6 -- .../tests/mocha/keyboard_movement_test.js | 95 +++++++++++-------- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 42b3edc647d..39dff646ec2 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -256,17 +256,21 @@ 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) { 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) { - return !e.shiftKey; + // 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 !!this.block.previousConnection; + return false; } } diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index 9e591d87594..ea2aefc36f8 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -3,8 +3,6 @@ * Copyright 2026 Raspberry Pi Foundation * SPDX-License-Identifier: Apache-2.0 */ - -import {BlockSvg} from '../blockly.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IDragger} from '../interfaces/i_dragger.js'; import * as registry from '../registry.js'; @@ -170,10 +168,6 @@ export class KeyboardMover { this.moveIndicator = new MoveIndicator(this.draggable.workspace); this.repositionMoveIndicator(); - console.log( - (this.draggable as BlockSvg).previousConnection.targetBlock()?.id, - ); - console.log((this.draggable as BlockSvg).nextConnection.targetBlock()?.id); return true; } diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index ce21b93daba..736aab560a9 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -431,69 +431,80 @@ suite('Keyboard-driven movement', function () { this.block3.initSvg(); this.block3.render(); this.block2.nextConnection.connect(this.block3.previousConnection); - console.log(Blockly.serialization.blocks.save(this.workspace)); }); - test('Top block - Detach single block', function () { - startMove(this.block1, false); - - // Only block1 should be detached; block2/3 remain connected. + test('from top block - Detaches single block', function () { + Blockly.getFocusManager().focusNode(this.block1); + startMove(this.workspace); assert.isNull(this.block1.nextConnection.targetBlock()); - assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); - cancelMove(this.block1); + assert.equal(this.block1.isDragging(), true); + assert.equal(this.block2.isDragging(), false); + assert.equal(this.block3.isDragging(), false); + cancelMove(this.workspace); }); - test('Top block - Detach three-block stack', function () { - startMoveStack(this.block1, true); - - // All three blocks should be treated as moving together. - assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); - cancelMove(this.block1); + 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('Middle block - Detach single block', function () { - startMove(this.block2, false); - assert.isNull(this.block2.previousConnection.targetBlock()); - assert.isNull(this.block2.nextConnection.targetBlock()); - assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block3); - cancelMove(this.block2); + 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('Middle block - Detaches two-block stack from middle down', function () { - startMoveStack(this.block2, true); - // block2 and block3 detached; block1 stays. - assert.isNull(this.block2.previousConnection.targetBlock()); + 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.strictEqual(this.block1.nextConnection.targetBlock(), null); - cancelMove(this.block2); + assert.equal(this.block1.isDragging(), true); + assert.equal(this.block2.isDragging(), true); + assert.equal(this.block3.isDragging(), true); + cancelMove(this.workspace); }); - test('Bottom block - Detach single block', function () { - startMove(this.block3, false); - // Only block3 should be detached; block1/2 remain connected. - assert.isNull(this.block3.previousConnection.targetBlock()); - assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - cancelMove(this.block3); + 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('Bottom block - Detaches single block stack from bottom', function () { - startMoveStack(this.block3, true); - // Only block3 is disconnected, behaves same as single block move - assert.isNull(this.block3.previousConnection.targetBlock()); - assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); - cancelMove(this.block3); + 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 () { - startMove(this.block2, true); - cancelMove(this.block2); + 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); - startMoveStack(this.block2, true); - cancelMove(this.block2); + 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);