From 93a9b6bf2e20a1fa4830f52dff5116e2aceb2167 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 13 Jun 2025 15:08:58 -0700 Subject: [PATCH] fix: Fix navigation for blocks with multiple statement inputs. (#9143) * fix: Fix navigation for blocks with multiple statement inputs. * chore: Add tests to prevent regressions. --- core/keyboard_nav/block_navigation_policy.ts | 25 +++- tests/mocha/cursor_test.js | 133 +++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 570b06fe3..2637ad49d 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -24,7 +24,7 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { - const candidates = getBlockNavigationCandidates(current); + const candidates = getBlockNavigationCandidates(current, true); return candidates[0]; } @@ -58,6 +58,8 @@ export class BlockNavigationPolicy implements INavigationPolicy { return current.nextConnection?.targetBlock(); } else if (current.outputConnection?.targetBlock()) { return navigateBlock(current, 1); + } else if (current.getSurroundParent()) { + return navigateBlock(current.getTopStackBlock(), 1); } else if (this.getParent(current) instanceof WorkspaceSvg) { return navigateStacks(current, 1); } @@ -111,14 +113,27 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @param block The block to retrieve the navigable children of. * @returns A list of navigable/focusable children of the given block. */ -function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { +function getBlockNavigationCandidates( + block: BlockSvg, + forward: boolean, +): IFocusableNode[] { const candidates: IFocusableNode[] = block.getIcons(); for (const input of block.inputList) { if (!input.isVisible()) continue; candidates.push(...input.fieldRow); if (input.connection?.targetBlock()) { - candidates.push(input.connection.targetBlock() as BlockSvg); + const connectedBlock = input.connection.targetBlock() as BlockSvg; + if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + const lastStackBlock = connectedBlock + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (lastStackBlock) { + candidates.push(lastStackBlock); + } + } else { + candidates.push(connectedBlock); + } } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { candidates.push(input.connection as RenderedConnection); } @@ -174,11 +189,11 @@ export function navigateBlock( ): IFocusableNode | null { const block = current instanceof BlockSvg - ? current.outputConnection.targetBlock() + ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) : current.getSourceBlock(); if (!(block instanceof BlockSvg)) return null; - const candidates = getBlockNavigationCandidates(block); + const candidates = getBlockNavigationCandidates(block, delta > 0); const currentIndex = candidates.indexOf(current); if (currentIndex === -1) return null; diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 1d283f331..6f841ae09 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -60,6 +60,33 @@ suite('Cursor', function () { 'tooltip': '', 'helpUrl': '', }, + { + 'type': 'multi_statement_input', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'FIRST', + }, + { + 'type': 'input_statement', + 'name': 'SECOND', + }, + ], + }, + { + 'type': 'simple_statement', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.cursor = this.workspace.getCursor(); @@ -145,6 +172,112 @@ suite('Cursor', function () { assert.equal(curNode, this.blocks.D.nextConnection); }); }); + + suite('Multiple statement inputs', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'multi_statement_input', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'FIRST', + }, + { + 'type': 'input_statement', + 'name': 'SECOND', + }, + ], + }, + { + 'type': 'simple_statement', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + + this.multiStatement1 = createRenderedBlock( + this.workspace, + 'multi_statement_input', + ); + this.multiStatement2 = createRenderedBlock( + this.workspace, + 'multi_statement_input', + ); + this.firstStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.secondStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.thirdStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.fourthStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.multiStatement1 + .getInput('FIRST') + .connection.connect(this.firstStatement.previousConnection); + this.firstStatement.nextConnection.connect( + this.secondStatement.previousConnection, + ); + this.multiStatement1 + .getInput('SECOND') + .connection.connect(this.thirdStatement.previousConnection); + this.multiStatement2 + .getInput('FIRST') + .connection.connect(this.fourthStatement.previousConnection); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('In - from field in nested statement block to next nested statement block', function () { + this.cursor.setCurNode(this.secondStatement.getField('NAME')); + this.cursor.in(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.thirdStatement); + }); + test('In - from field in nested statement block to next stack', function () { + this.cursor.setCurNode(this.thirdStatement.getField('NAME')); + this.cursor.in(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.multiStatement2); + }); + + test('Out - from nested statement block to last field of previous nested statement block', function () { + this.cursor.setCurNode(this.thirdStatement); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.secondStatement.getField('NAME')); + }); + + test('Out - from root block to last field of last nested statement block in previous stack', function () { + this.cursor.setCurNode(this.multiStatement2); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.thirdStatement.getField('NAME')); + }); + }); + suite('Searching', function () { setup(function () { sharedTestSetup.call(this);