diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index d8e0a472b..b2bda39c7 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -99,7 +99,11 @@ export class LineCursor extends Marker { if (!curNode) { return null; } - const newNode = this.getNextNode(curNode, this.validLineNode.bind(this)); + const newNode = this.getNextNode( + curNode, + this.validLineNode.bind(this), + true, + ); if (newNode) { this.setCurNode(newNode); @@ -119,7 +123,11 @@ export class LineCursor extends Marker { if (!curNode) { return null; } - const newNode = this.getNextNode(curNode, this.validInLineNode.bind(this)); + const newNode = this.getNextNode( + curNode, + this.validInLineNode.bind(this), + true, + ); if (newNode) { this.setCurNode(newNode); @@ -138,11 +146,10 @@ export class LineCursor extends Marker { if (!curNode) { return null; } - const newNode = this.getPreviousNode( + const newNode = this.getPreviousNodeImpl( curNode, this.validLineNode.bind(this), ); - if (newNode) { this.setCurNode(newNode); } @@ -161,7 +168,7 @@ export class LineCursor extends Marker { if (!curNode) { return null; } - const newNode = this.getPreviousNode( + const newNode = this.getPreviousNodeImpl( curNode, this.validInLineNode.bind(this), ); @@ -184,6 +191,7 @@ export class LineCursor extends Marker { const rightNode = this.getNextNode( curNode, this.validInLineNode.bind(this), + false, ); return this.validLineNode(rightNode); } @@ -299,28 +307,46 @@ export class LineCursor extends Marker { * should be traversed. * @returns The next node in the traversal. */ - getNextNode( + private getNextNodeImpl( node: ASTNode | null, isValid: (p1: ASTNode | null) => boolean, ): ASTNode | null { - if (!node) { - return null; - } - const newNode = node.in() || node.next(); - if (isValid(newNode)) { - return newNode; - } else if (newNode) { - return this.getNextNode(newNode, isValid); - } - const siblingOrParentSibling = this.findSiblingOrParentSibling(node.out()); - if (isValid(siblingOrParentSibling)) { - return siblingOrParentSibling; - } else if (siblingOrParentSibling) { - return this.getNextNode(siblingOrParentSibling, isValid); - } + if (!node) return null; + let newNode = node.in() || node.next(); + if (isValid(newNode)) return newNode; + if (newNode) return this.getNextNodeImpl(newNode, isValid); + + newNode = this.findSiblingOrParentSibling(node.out()); + if (isValid(newNode)) return newNode; + if (newNode) return this.getNextNodeImpl(newNode, isValid); return null; } + /** + * Get the next node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the beginning of the workspace if + * novalid node was found. + * @returns The next node in the traversal. + */ + getNextNode( + node: ASTNode | null, + isValid: (p1: ASTNode | null) => boolean, + loop: boolean, + ): ASTNode | null { + if (!node) return null; + + const potential = this.getNextNodeImpl(node, isValid); + if (potential || !loop) return potential; + // Loop back. + const firstNode = this.getFirstNode(); + if (isValid(firstNode)) return firstNode; + return this.getNextNodeImpl(firstNode, isValid); + } + /** * Reverses the pre order traversal in order to find the previous node. This * will allow a user to easily navigate the entire Blockly AST without having @@ -332,13 +358,11 @@ export class LineCursor extends Marker { * @returns The previous node in the traversal or null if no previous node * exists. */ - getPreviousNode( + private getPreviousNodeImpl( node: ASTNode | null, isValid: (p1: ASTNode | null) => boolean, ): ASTNode | null { - if (!node) { - return null; - } + if (!node) return null; let newNode: ASTNode | null = node.prev(); if (newNode) { @@ -346,14 +370,38 @@ export class LineCursor extends Marker { } else { newNode = node.out(); } - if (isValid(newNode)) { - return newNode; - } else if (newNode) { - return this.getPreviousNode(newNode, isValid); - } + + if (isValid(newNode)) return newNode; + if (newNode) return this.getPreviousNodeImpl(newNode, isValid); return null; } + /** + * Get the previous node in the AST, optionally allowing for loopback. + * + * @param node The current position in the AST. + * @param isValid A function true/false depending on whether the given node + * should be traversed. + * @param loop Whether to loop around to the end of the workspace if no + * valid node was found. + * @returns The previous node in the traversal or null if no previous node + * exists. + */ + getPreviousNode( + node: ASTNode | null, + isValid: (p1: ASTNode | null) => boolean, + loop: boolean, + ): ASTNode | null { + if (!node) return null; + + const potential = this.getPreviousNodeImpl(node, isValid); + if (potential || !loop) return potential; + // Loop back. + const lastNode = this.getLastNode(); + if (isValid(lastNode)) return lastNode; + return this.getPreviousNodeImpl(lastNode, isValid); + } + /** * From the given node find either the next valid sibling or the parent's * next sibling. @@ -362,13 +410,9 @@ export class LineCursor extends Marker { * @returns The next sibling node, the parent's next sibling, or null. */ private findSiblingOrParentSibling(node: ASTNode | null): ASTNode | null { - if (!node) { - return null; - } + if (!node) return null; const nextNode = node.next(); - if (nextNode) { - return nextNode; - } + if (nextNode) return nextNode; return this.findSiblingOrParentSibling(node.out()); } @@ -381,9 +425,7 @@ export class LineCursor extends Marker { */ private getRightMostChild(node: ASTNode): ASTNode | null { let newNode = node.in(); - if (!newNode) { - return node; - } + if (!newNode) return node; for ( let nextNode: ASTNode | null = newNode; nextNode; @@ -787,9 +829,13 @@ export class LineCursor extends Marker { // Iterate until you fall off the end of the stack. while (nextNode) { prevNode = nextNode; - nextNode = this.getNextNode(prevNode, (node) => { - return !!node; - }); + nextNode = this.getNextNode( + prevNode, + (node) => { + return !!node; + }, + false, + ); } return prevNode; } diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index b2a382688..905f48c09 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -385,4 +385,444 @@ suite('Cursor', function () { }); }); }); + suite('Get next node', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'empty_block', + 'message0': '', + }, + { + 'type': 'stack_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'row_block', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + this.neverValid = () => false; + this.alwaysValid = () => true; + this.isConnection = (node) => { + return node && node.isConnection(); + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + suite('stack', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'C', + }, + }, + }, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(state, this.workspace); + this.blockA = this.workspace.getBlockById('A'); + this.blockB = this.workspace.getBlockById('B'); + this.blockC = this.workspace.getBlockById('C'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('Never valid - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(nextNode); + }); + + test('Always valid - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode.getLocation(), this.blockA); + }); + test('Always valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(nextNode.getLocation(), this.blockB.getField('FIELD')); + }); + test('Always valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + false, + ); + assert.isNull(nextNode); + }); + + test('Valid if connection - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.equal(nextNode.getLocation(), this.blockA.nextConnection); + }); + test('Valid if connection - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.equal(nextNode.getLocation(), this.blockB.nextConnection); + }); + test('Valid if connection - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + false, + ); + assert.isNull(nextNode); + }); + test('Never valid - start at end - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.neverValid, + true, + ); + assert.isNull(nextNode); + }); + test('Always valid - start at end - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.alwaysValid, + true, + ); + assert.equal(nextNode.getLocation(), this.blockA.previousConnection); + }); + + test('Valid if connection - start at end - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const nextNode = this.cursor.getNextNode( + startNode, + this.isConnection, + true, + ); + assert.equal(nextNode.getLocation(), this.blockA.previousConnection); + }); + }); + }); + + suite('Get previous node', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'empty_block', + 'message0': '', + }, + { + 'type': 'stack_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + { + 'type': 'row_block', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'INPUT', + }, + ], + 'output': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + this.neverValid = () => false; + this.alwaysValid = () => true; + this.isConnection = (node) => { + return node && node.isConnection(); + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + suite('stack', function () { + setup(function () { + const state = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'stack_block', + 'id': 'A', + 'x': 0, + 'y': 0, + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'B', + 'next': { + 'block': { + 'type': 'stack_block', + 'id': 'C', + }, + }, + }, + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(state, this.workspace); + this.blockA = this.workspace.getBlockById('A'); + this.blockB = this.workspace.getBlockById('B'); + this.blockC = this.workspace.getBlockById('C'); + }); + teardown(function () { + this.workspace.clear(); + }); + test('Never valid - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + test('Never valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + false, + ); + assert.isNull(previousNode); + }); + + test('Always valid - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.isNotNull(previousNode); + }); + test('Always valid - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockB.previousConnection, + ); + }); + test('Always valid - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + false, + ); + assert.equal(previousNode.getLocation(), this.blockC.getField('FIELD')); + }); + + test('Valid if connection - start at top', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.isNull(previousNode); + }); + test('Valid if connection - start in middle', function () { + const startNode = ASTNode.createBlockNode(this.blockB); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockB.previousConnection, + ); + }); + test('Valid if connection - start at end', function () { + const startNode = ASTNode.createConnectionNode( + this.blockC.nextConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + false, + ); + assert.equal( + previousNode.getLocation(), + this.blockC.previousConnection, + ); + }); + test('Never valid - start at top - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.neverValid, + true, + ); + assert.isNull(previousNode); + }); + test('Always valid - start at top - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.alwaysValid, + true, + ); + // Previous node will be a stack node in this case. + assert.equal(previousNode.getLocation(), this.blockA); + }); + test('Valid if connection - start at top - with loopback', function () { + const startNode = ASTNode.createConnectionNode( + this.blockA.previousConnection, + ); + const previousNode = this.cursor.getPreviousNode( + startNode, + this.isConnection, + true, + ); + assert.equal(previousNode.getLocation(), this.blockC.nextConnection); + }); + }); + }); });