Skip to content

Commit 38df7c8

Browse files
authored
feat: Allow visiting empty input connections. (#9104)
* feat: Update navigation policies to allow visiting empty input connections. * fix: Fix tests. * chore: Add JSDoc. * fix: Add missing import. * fix: Fix JSDoc. * chore: Remove double comments.
1 parent b0b685a commit 38df7c8

File tree

6 files changed

+150
-224
lines changed

6 files changed

+150
-224
lines changed

core/keyboard_nav/block_navigation_policy.ts

Lines changed: 99 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
*/
66

77
import {BlockSvg} from '../block_svg.js';
8+
import {ConnectionType} from '../connection_type.js';
89
import type {Field} from '../field.js';
10+
import type {Icon} from '../icons/icon.js';
911
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
1012
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
13+
import {RenderedConnection} from '../rendered_connection.js';
1114
import {WorkspaceSvg} from '../workspace_svg.js';
1215

1316
/**
@@ -21,21 +24,8 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
2124
* @returns The first field or input of the given block, if any.
2225
*/
2326
getFirstChild(current: BlockSvg): IFocusableNode | null {
24-
const icons = current.getIcons();
25-
if (icons.length) return icons[0];
26-
27-
for (const input of current.inputList) {
28-
if (!input.isVisible()) {
29-
continue;
30-
}
31-
for (const field of input.fieldRow) {
32-
return field;
33-
}
34-
if (input.connection?.targetBlock())
35-
return input.connection.targetBlock() as BlockSvg;
36-
}
37-
38-
return null;
27+
const candidates = getBlockNavigationCandidates(current);
28+
return candidates[0];
3929
}
4030

4131
/**
@@ -66,36 +56,10 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
6656
getNextSibling(current: BlockSvg): IFocusableNode | null {
6757
if (current.nextConnection?.targetBlock()) {
6858
return current.nextConnection?.targetBlock();
69-
}
70-
71-
const parent = this.getParent(current);
72-
let navigatingCrossStacks = false;
73-
let siblings: (BlockSvg | Field)[] = [];
74-
if (parent instanceof BlockSvg) {
75-
for (let i = 0, input; (input = parent.inputList[i]); i++) {
76-
if (!input.isVisible()) {
77-
continue;
78-
}
79-
siblings.push(...input.fieldRow);
80-
const child = input.connection?.targetBlock();
81-
if (child) {
82-
siblings.push(child as BlockSvg);
83-
}
84-
}
85-
} else if (parent instanceof WorkspaceSvg) {
86-
siblings = parent.getTopBlocks(true);
87-
navigatingCrossStacks = true;
88-
} else {
89-
return null;
90-
}
91-
92-
const currentIndex = siblings.indexOf(
93-
navigatingCrossStacks ? current.getRootBlock() : current,
94-
);
95-
if (currentIndex >= 0 && currentIndex < siblings.length - 1) {
96-
return siblings[currentIndex + 1];
97-
} else if (currentIndex === siblings.length - 1 && navigatingCrossStacks) {
98-
return siblings[0];
59+
} else if (current.outputConnection?.targetBlock()) {
60+
return navigateBlock(current, 1);
61+
} else if (this.getParent(current) instanceof WorkspaceSvg) {
62+
return navigateStacks(current, 1);
9963
}
10064

10165
return null;
@@ -111,44 +75,13 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
11175
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
11276
if (current.previousConnection?.targetBlock()) {
11377
return current.previousConnection?.targetBlock();
78+
} else if (current.outputConnection?.targetBlock()) {
79+
return navigateBlock(current, -1);
80+
} else if (this.getParent(current) instanceof WorkspaceSvg) {
81+
return navigateStacks(current, -1);
11482
}
11583

116-
const parent = this.getParent(current);
117-
let navigatingCrossStacks = false;
118-
let siblings: (BlockSvg | Field)[] = [];
119-
if (parent instanceof BlockSvg) {
120-
for (let i = 0, input; (input = parent.inputList[i]); i++) {
121-
if (!input.isVisible()) {
122-
continue;
123-
}
124-
siblings.push(...input.fieldRow);
125-
const child = input.connection?.targetBlock();
126-
if (child) {
127-
siblings.push(child as BlockSvg);
128-
}
129-
}
130-
} else if (parent instanceof WorkspaceSvg) {
131-
siblings = parent.getTopBlocks(true);
132-
navigatingCrossStacks = true;
133-
} else {
134-
return null;
135-
}
136-
137-
const currentIndex = siblings.indexOf(current);
138-
let result: IFocusableNode | null = null;
139-
if (currentIndex >= 1) {
140-
result = siblings[currentIndex - 1];
141-
} else if (currentIndex === 0 && navigatingCrossStacks) {
142-
result = siblings[siblings.length - 1];
143-
}
144-
145-
// If navigating to a previous stack, our previous sibling is the last
146-
// block in it.
147-
if (navigatingCrossStacks && result instanceof BlockSvg) {
148-
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
149-
}
150-
151-
return result;
84+
return null;
15285
}
15386

15487
/**
@@ -171,3 +104,88 @@ export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
171104
return current instanceof BlockSvg;
172105
}
173106
}
107+
108+
/**
109+
* Returns a list of the navigable children of the given block.
110+
*
111+
* @param block The block to retrieve the navigable children of.
112+
* @returns A list of navigable/focusable children of the given block.
113+
*/
114+
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
115+
const candidates: IFocusableNode[] = block.getIcons();
116+
117+
for (const input of block.inputList) {
118+
if (!input.isVisible()) continue;
119+
candidates.push(...input.fieldRow);
120+
if (input.connection?.targetBlock()) {
121+
candidates.push(input.connection.targetBlock() as BlockSvg);
122+
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
123+
candidates.push(input.connection as RenderedConnection);
124+
}
125+
}
126+
127+
return candidates;
128+
}
129+
130+
/**
131+
* Returns the next/previous stack relative to the given block's stack.
132+
*
133+
* @param current The block whose stack will be navigated relative to.
134+
* @param delta The difference in index to navigate; positive values navigate
135+
* to the nth next stack, while negative values navigate to the nth previous
136+
* stack.
137+
* @returns The first block in the stack offset by `delta` relative to the
138+
* current block's stack, or the last block in the stack offset by `delta`
139+
* relative to the current block's stack when navigating backwards.
140+
*/
141+
export function navigateStacks(current: BlockSvg, delta: number) {
142+
const stacks = current.workspace.getTopBlocks(true);
143+
const currentIndex = stacks.indexOf(current.getRootBlock());
144+
const targetIndex = currentIndex + delta;
145+
let result: BlockSvg | null = null;
146+
if (targetIndex >= 0 && targetIndex < stacks.length) {
147+
result = stacks[targetIndex];
148+
} else if (targetIndex < 0) {
149+
result = stacks[stacks.length - 1];
150+
} else if (targetIndex >= stacks.length) {
151+
result = stacks[0];
152+
}
153+
154+
// When navigating to a previous stack, our previous sibling is the last
155+
// block in it.
156+
if (delta < 0 && result) {
157+
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
158+
}
159+
160+
return result;
161+
}
162+
163+
/**
164+
* Returns the next navigable item relative to the provided block child.
165+
*
166+
* @param current The navigable block child item to navigate relative to.
167+
* @param delta The difference in index to navigate; positive values navigate
168+
* forward by n, while negative values navigate backwards by n.
169+
* @returns The navigable block child offset by `delta` relative to `current`.
170+
*/
171+
export function navigateBlock(
172+
current: Icon | Field | RenderedConnection | BlockSvg,
173+
delta: number,
174+
): IFocusableNode | null {
175+
const block =
176+
current instanceof BlockSvg
177+
? current.outputConnection.targetBlock()
178+
: current.getSourceBlock();
179+
if (!(block instanceof BlockSvg)) return null;
180+
181+
const candidates = getBlockNavigationCandidates(block);
182+
const currentIndex = candidates.indexOf(current);
183+
if (currentIndex === -1) return null;
184+
185+
const targetIndex = currentIndex + delta;
186+
if (targetIndex >= 0 && targetIndex < candidates.length) {
187+
return candidates[targetIndex];
188+
}
189+
190+
return null;
191+
}

core/keyboard_nav/connection_navigation_policy.ts

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {ConnectionType} from '../connection_type.js';
99
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
1010
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
1111
import {RenderedConnection} from '../rendered_connection.js';
12+
import {navigateBlock} from './block_navigation_policy.js';
1213

1314
/**
1415
* Set of rules controlling keyboard navigation from a connection.
@@ -37,17 +38,7 @@ export class ConnectionNavigationPolicy
3738
* @returns The given connection's parent connection or block.
3839
*/
3940
getParent(current: RenderedConnection): IFocusableNode | null {
40-
if (current.type === ConnectionType.OUTPUT_VALUE) {
41-
return current.targetConnection ?? current.getSourceBlock();
42-
} else if (current.getParentInput()) {
43-
return current.getSourceBlock();
44-
}
45-
46-
const topBlock = current.getSourceBlock().getTopStackBlock();
47-
return (
48-
(this.getParentConnection(topBlock)?.targetConnection?.getParentInput()
49-
?.connection as RenderedConnection) ?? topBlock
50-
);
41+
return current.getSourceBlock();
5142
}
5243

5344
/**
@@ -58,19 +49,7 @@ export class ConnectionNavigationPolicy
5849
*/
5950
getNextSibling(current: RenderedConnection): IFocusableNode | null {
6051
if (current.getParentInput()) {
61-
const parentInput = current.getParentInput();
62-
const block = parentInput?.getSourceBlock();
63-
if (!block || !parentInput) return null;
64-
65-
const curIdx = block.inputList.indexOf(parentInput);
66-
for (let i = curIdx + 1; i < block.inputList.length; i++) {
67-
const input = block.inputList[i];
68-
const fieldRow = input.fieldRow;
69-
if (fieldRow.length) return fieldRow[0];
70-
if (input.connection) return input.connection as RenderedConnection;
71-
}
72-
73-
return null;
52+
return navigateBlock(current, 1);
7453
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
7554
const nextBlock = current.targetConnection;
7655
// If this connection is the last one in the stack, our next sibling is
@@ -103,20 +82,7 @@ export class ConnectionNavigationPolicy
10382
*/
10483
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
10584
if (current.getParentInput()) {
106-
const parentInput = current.getParentInput();
107-
const block = parentInput?.getSourceBlock();
108-
if (!block || !parentInput) return null;
109-
110-
const curIdx = block.inputList.indexOf(parentInput);
111-
for (let i = curIdx; i >= 0; i--) {
112-
const input = block.inputList[i];
113-
if (input.connection && input !== parentInput) {
114-
return input.connection as RenderedConnection;
115-
}
116-
const fieldRow = input.fieldRow;
117-
if (fieldRow.length) return fieldRow[fieldRow.length - 1];
118-
}
119-
return null;
85+
return navigateBlock(current, -1);
12086
} else if (
12187
current.type === ConnectionType.PREVIOUS_STATEMENT ||
12288
current.type === ConnectionType.OUTPUT_VALUE

core/keyboard_nav/field_navigation_policy.ts

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {BlockSvg} from '../block_svg.js';
88
import {Field} from '../field.js';
99
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
1010
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
11+
import {navigateBlock} from './block_navigation_policy.js';
1112

1213
/**
1314
* Set of rules controlling keyboard navigation from a field.
@@ -40,24 +41,7 @@ export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
4041
* @returns The next field or input in the given field's block.
4142
*/
4243
getNextSibling(current: Field<any>): IFocusableNode | null {
43-
const input = current.getParentInput();
44-
const block = current.getSourceBlock();
45-
if (!block) return null;
46-
47-
const curIdx = block.inputList.indexOf(input);
48-
let fieldIdx = input.fieldRow.indexOf(current) + 1;
49-
for (let i = curIdx; i < block.inputList.length; i++) {
50-
const newInput = block.inputList[i];
51-
if (newInput.isVisible()) {
52-
const fieldRow = newInput.fieldRow;
53-
if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx];
54-
if (newInput.connection?.targetBlock()) {
55-
return newInput.connection.targetBlock() as BlockSvg;
56-
}
57-
}
58-
fieldIdx = 0;
59-
}
60-
return null;
44+
return navigateBlock(current, 1);
6145
}
6246

6347
/**
@@ -67,29 +51,7 @@ export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
6751
* @returns The preceding field or input in the given field's block.
6852
*/
6953
getPreviousSibling(current: Field<any>): IFocusableNode | null {
70-
const parentInput = current.getParentInput();
71-
const block = current.getSourceBlock();
72-
if (!block) return null;
73-
74-
const curIdx = block.inputList.indexOf(parentInput);
75-
let fieldIdx = parentInput.fieldRow.indexOf(current) - 1;
76-
for (let i = curIdx; i >= 0; i--) {
77-
const input = block.inputList[i];
78-
if (input.isVisible()) {
79-
if (input.connection?.targetBlock() && input !== parentInput) {
80-
return input.connection.targetBlock() as BlockSvg;
81-
}
82-
const fieldRow = input.fieldRow;
83-
if (fieldIdx > -1) return fieldRow[fieldIdx];
84-
}
85-
// Reset the fieldIdx to the length of the field row of the previous
86-
// input.
87-
if (i - 1 >= 0) {
88-
fieldIdx = block.inputList[i - 1].fieldRow.length - 1;
89-
}
90-
}
91-
92-
return block.getIcons().pop() ?? null;
54+
return navigateBlock(current, -1);
9355
}
9456

9557
/**

core/keyboard_nav/icon_navigation_policy.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {BlockSvg} from '../block_svg.js';
88
import {Icon} from '../icons/icon.js';
99
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
1010
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
11+
import {navigateBlock} from './block_navigation_policy.js';
1112

1213
/**
1314
* Set of rules controlling keyboard navigation from an icon.
@@ -40,21 +41,7 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
4041
* @returns The next icon, field or input following this icon, if any.
4142
*/
4243
getNextSibling(current: Icon): IFocusableNode | null {
43-
const block = current.getSourceBlock() as BlockSvg;
44-
const icons = block.getIcons();
45-
const currentIndex = icons.indexOf(current);
46-
if (currentIndex >= 0 && currentIndex + 1 < icons.length) {
47-
return icons[currentIndex + 1];
48-
}
49-
50-
for (const input of block.inputList) {
51-
if (input.fieldRow.length) return input.fieldRow[0];
52-
53-
if (input.connection?.targetBlock())
54-
return input.connection.targetBlock() as BlockSvg;
55-
}
56-
57-
return null;
44+
return navigateBlock(current, 1);
5845
}
5946

6047
/**
@@ -64,14 +51,7 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
6451
* @returns The icon's previous icon, if any.
6552
*/
6653
getPreviousSibling(current: Icon): IFocusableNode | null {
67-
const block = current.getSourceBlock() as BlockSvg;
68-
const icons = block.getIcons();
69-
const currentIndex = icons.indexOf(current);
70-
if (currentIndex >= 1) {
71-
return icons[currentIndex - 1];
72-
}
73-
74-
return null;
54+
return navigateBlock(current, -1);
7555
}
7656

7757
/**

0 commit comments

Comments
 (0)