diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 0bdb726b8c0..2b3a8eea2e4 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1905,4 +1905,40 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Returns a set of all of the parent blocks of the given block. + * + * @internal + * @returns A set of the parents of the given block. + */ + getParents(): Set { + const parents = new Set(); + let parent = this.getParent(); + while (parent) { + parents.add(parent); + parent = parent.getParent(); + } + + return parents; + } + + /** + * Returns a set of all of the parent blocks connected to an output of the + * given block or one of its parents. Also includes the given block. + * + * @internal + * @returns A set of the output-connected parents of the given block. + */ + getOutputParents(): Set { + const parents = new Set(); + parents.add(this); + let parent = this.outputConnection?.targetBlock(); + while (parent) { + parents.add(parent); + parent = parent.outputConnection?.targetBlock(); + } + + return parents; + } } diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 7377ff9098b..95c1b62e177 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -177,15 +177,13 @@ import { import {IVariableMap} from './interfaces/i_variable_map.js'; import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import {Marker} from './keyboard_nav/marker.js'; +import {ToolboxNavigator} from './keyboard_nav/navigators/toolbox_navigator.js'; import { KeyboardNavigationController, keyboardNavigationController, } from './keyboard_navigation_controller.js'; import type {LayerManager} from './layer_manager.js'; import * as layers from './layers.js'; -import {MarkerManager} from './marker_manager.js'; import {Menu} from './menu.js'; import {MenuItem} from './menuitem.js'; import {MetricsManager} from './metrics_manager.js'; @@ -439,16 +437,22 @@ Names.prototype.populateProcedures = function ( }; // clang-format on -export * from './flyout_navigator.js'; export * from './interfaces/i_navigation_policy.js'; -export * from './keyboard_nav/block_navigation_policy.js'; -export * from './keyboard_nav/connection_navigation_policy.js'; -export * from './keyboard_nav/field_navigation_policy.js'; -export * from './keyboard_nav/flyout_button_navigation_policy.js'; -export * from './keyboard_nav/flyout_navigation_policy.js'; -export * from './keyboard_nav/flyout_separator_navigation_policy.js'; -export * from './keyboard_nav/workspace_navigation_policy.js'; -export * from './navigator.js'; +export * from './keyboard_nav/navigation_policies/block_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/block_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/field_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_separator_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/icon_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/toolbox_item_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js'; +export * from './keyboard_nav/navigators/flyout_navigator.js'; +export * from './keyboard_nav/navigators/navigator.js'; export * from './toast.js'; // Re-export submodules that no longer declareLegacyNamespace. @@ -471,7 +475,6 @@ export { DragTarget, Events, Extensions, - LineCursor, Procedures, ShortcutItems, Themes, @@ -596,8 +599,6 @@ export { KeyboardNavigationController, LabelFlyoutInflater, LayerManager, - Marker, - MarkerManager, Menu, MenuGenerator, MenuGeneratorFunction, @@ -619,6 +620,7 @@ export { Toolbox, ToolboxCategory, ToolboxItem, + ToolboxNavigator, ToolboxSeparator, Trashcan, UnattachedFieldError, diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index a8377ae050f..dca834fb9ee 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -15,6 +15,7 @@ import './events/events_block_change.js'; import {BlockSvg} from './block_svg.js'; +import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as bumpObjects from './bump_objects.js'; import * as dialog from './dialog.js'; @@ -28,7 +29,6 @@ import { UnattachedFieldError, } from './field.js'; import {getFocusManager} from './focus_manager.js'; -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; @@ -600,16 +600,20 @@ export abstract class FieldInput extends Field< dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Tab') { e.preventDefault(); - const cursor = this.workspace_?.getCursor(); + const navigator = this.workspace_?.getNavigator(); const isValidDestination = (node: IFocusableNode | null) => (node instanceof FieldInput || (node instanceof BlockSvg && node.isSimpleReporter())) && node !== this.getSourceBlock(); - let target = e.shiftKey - ? cursor?.getPreviousNode(this, isValidDestination, false) - : cursor?.getNextNode(this, isValidDestination, false); + // eslint-disable-next-line @typescript-eslint/no-this-alias + let target: IFocusableNode | null | undefined = this; + do { + target = e.shiftKey + ? navigator?.getOutNode(target) + : navigator?.getInNode(target); + } while (target && !isValidDestination(target)); target = target instanceof BlockSvg && target.isSimpleReporter() ? target.getFields().next().value @@ -625,7 +629,9 @@ export abstract class FieldInput extends Field< targetSourceBlock instanceof BlockSvg ) { getFocusManager().focusNode(targetSourceBlock); - } else getFocusManager().focusNode(target); + } else { + getFocusManager().focusNode(target); + } target.showEditor(); } } diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index d89027ab4ca..00a0cfa2758 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -19,13 +19,11 @@ import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; -import {FlyoutNavigator} from './flyout_navigator.js'; import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; @@ -42,7 +40,7 @@ import {WorkspaceSvg} from './workspace_svg.js'; */ export abstract class Flyout extends DeleteArea - implements IAutoHideable, IFlyout, IFocusableNode + implements IAutoHideable, IFlyout { /** * Position the flyout. @@ -212,6 +210,7 @@ export abstract class Flyout // Keep the workspace visibility consistent with the flyout's visibility. this.workspace_.setVisible(this.visible); this.workspace_.setNavigator(new FlyoutNavigator(this)); + this.workspace_.keyboardAccessibilityMode = true; /** * The unique id for this component that is used to register with the @@ -797,86 +796,4 @@ export abstract class Flyout return null; } - - /** - * See IFocusableNode.getFocusableElement. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableElement(): HTMLElement | SVGElement { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getFocusableTree. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableTree(): IFocusableTree { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableNode.onNodeFocus. */ - onNodeFocus(): void {} - - /** See IFocusableNode.onNodeBlur. */ - onNodeBlur(): void {} - - /** See IFocusableNode.canBeFocused. */ - canBeFocused(): boolean { - return false; - } - - /** - * See IFocusableNode.getRootFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRootFocusableNode(): IFocusableNode { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getRestoredFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRestoredFocusableNode( - _previousNode: IFocusableNode | null, - ): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getNestedTrees. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getNestedTrees(): Array { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.lookUpFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - lookUpFocusableNode(_id: string): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableTree.onTreeFocus. */ - onTreeFocus( - _node: IFocusableNode, - _previousTree: IFocusableTree | null, - ): void {} - - /** - * See IFocusableNode.onTreeBlur. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - onTreeBlur(_nextTree: IFocusableTree | null): void { - throw new Error('Flyouts are not directly focusable.'); - } } diff --git a/packages/blockly/core/flyout_navigator.ts b/packages/blockly/core/flyout_navigator.ts deleted file mode 100644 index a102ce81765..00000000000 --- a/packages/blockly/core/flyout_navigator.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFlyout} from './interfaces/i_flyout.js'; -import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js'; -import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js'; -import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js'; -import {Navigator} from './navigator.js'; - -export class FlyoutNavigator extends Navigator { - constructor(flyout: IFlyout) { - super(); - this.rules.push( - new FlyoutButtonNavigationPolicy(), - new FlyoutSeparatorNavigationPolicy(), - ); - this.rules = this.rules.map( - (rule) => new FlyoutNavigationPolicy(rule, flyout), - ); - } -} diff --git a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts index 0b591b4a6ff..6e29e584304 100644 --- a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts @@ -6,7 +6,10 @@ // Former goog.module ID: Blockly.ICollapsibleToolboxItem -import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js'; +import { + type ISelectableToolboxItem, + isSelectableToolboxItem, +} from './i_selectable_toolbox_item.js'; import type {IToolboxItem} from './i_toolbox_item.js'; /** @@ -31,3 +34,18 @@ export interface ICollapsibleToolboxItem extends ISelectableToolboxItem { /** Toggles whether or not the toolbox item is expanded. */ toggleExpanded(): void; } + +/** + * Type guard that checks whether an object is an ICollapsibleToolboxItem. + */ +export function isCollapsibleToolboxItem( + obj: any, +): obj is ICollapsibleToolboxItem { + return ( + typeof obj.getChildToolboxItems === 'function' && + typeof obj.isExpanded === 'function' && + typeof obj.toggleExpanded === 'function' && + isSelectableToolboxItem(obj) && + obj.isCollapsible() + ); +} diff --git a/packages/blockly/core/interfaces/i_flyout.ts b/packages/blockly/core/interfaces/i_flyout.ts index 6906d5857b0..e826d17a8bd 100644 --- a/packages/blockly/core/interfaces/i_flyout.ts +++ b/packages/blockly/core/interfaces/i_flyout.ts @@ -10,13 +10,12 @@ import type {FlyoutItem} from '../flyout_item.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; -import {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; /** * Interface for a flyout. */ -export interface IFlyout extends IRegistrable, IFocusableTree { +export interface IFlyout extends IRegistrable { /** Whether the flyout is laid out horizontally or not. */ horizontalLayout: boolean; diff --git a/packages/blockly/core/interfaces/i_focusable_tree.ts b/packages/blockly/core/interfaces/i_focusable_tree.ts index c33189fcdf0..d3ed925caf5 100644 --- a/packages/blockly/core/interfaces/i_focusable_tree.ts +++ b/packages/blockly/core/interfaces/i_focusable_tree.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {Navigator} from '../keyboard_nav/navigators/navigator'; import type {IFocusableNode} from './i_focusable_node.js'; /** @@ -122,6 +123,14 @@ export interface IFocusableTree { * as in the case that Blockly is entirely losing DOM focus). */ onTreeBlur(nextTree: IFocusableTree | null): void; + + /** + * Returns a Navigator instance to be used to determine the navigation order + * between IFocusableNodes contained within this IFocusableTree. Generally + * this can just be an instance of Navigator, but trees may choose to return a + * subclass to customize navigation behavior within their context. + */ + getNavigator(): Navigator; } /** diff --git a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts index 890d4e370af..33fe4a7d4a6 100644 --- a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts @@ -7,7 +7,7 @@ // Former goog.module ID: Blockly.ISelectableToolboxItem import type {FlyoutItemInfoArray} from '../utils/toolbox'; -import type {IToolboxItem} from './i_toolbox_item.js'; +import {isToolboxItem, type IToolboxItem} from './i_toolbox_item.js'; /** * Interface for an item in the toolbox that can be selected. @@ -54,10 +54,17 @@ export interface ISelectableToolboxItem extends IToolboxItem { } /** - * Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem. + * Type guard that checks whether an object is an ISelectableToolboxItem. */ export function isSelectableToolboxItem( - toolboxItem: IToolboxItem, -): toolboxItem is ISelectableToolboxItem { - return toolboxItem.isSelectable(); + obj: any, +): obj is ISelectableToolboxItem { + return ( + typeof obj.getName === 'function' && + typeof obj.getContents === 'function' && + typeof obj.setSelected === 'function' && + typeof obj.onClick === 'function' && + isToolboxItem(obj) && + obj.isSelectable() + ); } diff --git a/packages/blockly/core/interfaces/i_toolbox.ts b/packages/blockly/core/interfaces/i_toolbox.ts index f5d9c9fd7c6..1116ae44040 100644 --- a/packages/blockly/core/interfaces/i_toolbox.ts +++ b/packages/blockly/core/interfaces/i_toolbox.ts @@ -9,7 +9,7 @@ import type {ToolboxInfo} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import type {IFlyout} from './i_flyout.js'; -import type {IFocusableTree} from './i_focusable_tree.js'; +import {type IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; import type {IToolboxItem} from './i_toolbox_item.js'; @@ -118,4 +118,9 @@ export interface IToolbox extends IRegistrable, IFocusableTree { /** Disposes of this toolbox. */ dispose(): void; + + /** + * Returns a list of items in this toolbox. + */ + getToolboxItems(): IToolboxItem[]; } diff --git a/packages/blockly/core/interfaces/i_toolbox_item.ts b/packages/blockly/core/interfaces/i_toolbox_item.ts index 661624fd7e8..25c4dab847a 100644 --- a/packages/blockly/core/interfaces/i_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_toolbox_item.ts @@ -7,6 +7,7 @@ // Former goog.module ID: Blockly.IToolboxItem import type {IFocusableNode} from './i_focusable_node.js'; +import type {IToolbox} from './i_toolbox.js'; /** * Interface for an item in the toolbox. @@ -80,4 +81,26 @@ export interface IToolboxItem extends IFocusableNode { * @param isVisible True if category should be visible. */ setVisible_(isVisible: boolean): void; + + getParentToolbox(): IToolbox; +} + +/** + * Type guard that checks whether an object is an IToolboxItem. + */ +export function isToolboxItem(obj: any): obj is IToolboxItem { + return ( + obj && + typeof obj.init === 'function' && + typeof obj.getDiv === 'function' && + typeof obj.getId === 'function' && + typeof obj.getParent === 'function' && + typeof obj.getLevel === 'function' && + typeof obj.isSelectable === 'function' && + typeof obj.isCollapsible === 'function' && + typeof obj.dispose === 'function' && + typeof obj.getClickTarget === 'function' && + typeof obj.setVisible_ === 'function' && + typeof obj.getParentToolbox === 'function' + ); } diff --git a/packages/blockly/core/keyboard_nav/line_cursor.ts b/packages/blockly/core/keyboard_nav/line_cursor.ts deleted file mode 100644 index 30770e47d2d..00000000000 --- a/packages/blockly/core/keyboard_nav/line_cursor.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview The class representing a line cursor. - * A line cursor tries to traverse the blocks and connections on a block as if - * they were lines of code in a text editor. Previous and next traverse previous - * connections, next connections and blocks, while in and out traverse input - * connections and fields. - * @author aschmiedt@google.com (Abby Schmiedt) - */ - -import {BlockSvg} from '../block_svg.js'; -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import {getFocusManager} from '../focus_manager.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import * as registry from '../registry.js'; -import type {WorkspaceSvg} from '../workspace_svg.js'; -import {Marker} from './marker.js'; - -/** - * Class for a line cursor. - */ -export class LineCursor extends Marker { - override type = 'cursor'; - - /** Locations to try moving the cursor to after a deletion. */ - private potentialNodes: IFocusableNode[] | null = null; - - /** - * @param workspace The workspace this cursor belongs to. - */ - constructor(protected readonly workspace: WorkspaceSvg) { - super(); - } - - /** - * Moves the cursor to the next block or workspace comment in the pre-order - * traversal. - * - * @returns The next node, or null if the current node is not set or there is - * no next value. - */ - next(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the next input connection or field - * in the pre order traversal. - * - * @returns The next node, or null if the current node is - * not set or there is no next value. - */ - in(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getNextNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - /** - * Moves the cursor to the previous block or workspace comment in the - * pre-order traversal. - * - * @returns The previous node, or null if the current node is not set or there - * is no previous value. - */ - prev(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getPreviousNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the previous input connection or field in the pre order - * traversal. - * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. - */ - out(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getPreviousNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Returns true iff the node to which we would navigate if in() were - * called is the same as the node to which we would navigate if next() were - * called - in effect, if the LineCursor is at the end of the 'current - * line' of the program. - */ - atEndOfLine(): boolean { - const curNode = this.getCurNode(); - if (!curNode) return false; - const inNode = this.getNextNode(curNode, () => true, true); - const nextNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() - ); - }, - true, - ); - - return inNode === nextNode; - } - - /** - * Uses pre order traversal to navigate the Blockly AST. This will allow - * a user to easily navigate the entire Blockly AST without having to go in - * and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The next node in the traversal. - */ - private getNextNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - let newNode = - this.workspace.getNavigator().getFirstChild(node) || - this.workspace.getNavigator().getNextSibling(node); - - let target = node; - while (target && !newNode) { - const parent = this.workspace.getNavigator().getParent(target); - if (!parent) break; - newNode = this.workspace.getNavigator().getNextSibling(parent); - target = parent; - } - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getNextNodeImpl(newNode, isValid, visitedNodes); - } - 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 no - * valid node was found. - * @returns The next node in the traversal. - */ - getNextNode( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getLastNode() === node)) return null; - - return this.getNextNodeImpl(node, 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 - * to go in and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - private getPreviousNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - - const newNode = - this.getRightMostChild( - this.workspace.getNavigator().getPreviousSibling(node), - node, - ) || this.workspace.getNavigator().getParent(node); - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getPreviousNodeImpl(newNode, isValid, visitedNodes); - } - 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: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getFirstNode() === node)) return null; - - return this.getPreviousNodeImpl(node, isValid); - } - - /** - * Get the right most child of a node. - * - * @param node The node to find the right most child of. - * @returns The right most child of the given node, or the node if no child - * exists. - */ - private getRightMostChild( - node: IFocusableNode | null, - stopIfFound: IFocusableNode, - ): IFocusableNode | null { - if (!node) return node; - let newNode = this.workspace.getNavigator().getFirstChild(node); - if (!newNode || newNode === stopIfFound) return node; - for ( - let nextNode: IFocusableNode | null = newNode; - nextNode; - nextNode = this.workspace.getNavigator().getNextSibling(newNode) - ) { - if (nextNode === stopIfFound) break; - newNode = nextNode; - } - return this.getRightMostChild(newNode, stopIfFound); - } - - /** - * Prepare for the deletion of a block by making a list of nodes we - * could move the cursor to afterwards and save it to - * this.potentialNodes. - * - * After the deletion has occurred, call postDelete to move it to - * the first valid node on that list. - * - * The locations to try (in order of preference) are: - * - * - The current location. - * - The connection to which the deleted block is attached. - * - The block connected to the next connection of the deleted block. - * - The parent block of the deleted block. - * - A location on the workspace beneath the deleted block. - * - * N.B.: When block is deleted, all of the blocks conneccted to that - * block's inputs are also deleted, but not blocks connected to its - * next connection. - * - * @param deletedBlock The block that is being deleted. - */ - preDelete(deletedBlock: BlockSvg) { - const curNode = this.getCurNode(); - - const nodes: IFocusableNode[] = curNode ? [curNode] : []; - // The connection to which the deleted block is attached. - const parentConnection = - deletedBlock.previousConnection?.targetConnection ?? - deletedBlock.outputConnection?.targetConnection; - if (parentConnection) { - nodes.push(parentConnection); - } - // The block connected to the next connection of the deleted block. - const nextBlock = deletedBlock.getNextBlock(); - if (nextBlock) { - nodes.push(nextBlock); - } - // The parent block of the deleted block. - const parentBlock = deletedBlock.getParent(); - if (parentBlock) { - nodes.push(parentBlock); - } - // A location on the workspace beneath the deleted block. - // Move to the workspace. - nodes.push(this.workspace); - this.potentialNodes = nodes; - } - - /** - * Move the cursor to the first valid location in - * this.potentialNodes, following a block deletion. - */ - postDelete() { - const nodes = this.potentialNodes; - this.potentialNodes = null; - if (!nodes) throw new Error('must call preDelete first'); - for (const node of nodes) { - if (!this.getSourceBlockFromNode(node)?.disposed) { - this.setCurNode(node); - return; - } - } - throw new Error('no valid nodes in this.potentialNodes'); - } - - /** - * Get the current location of the cursor. - * - * Overrides normal Marker getCurNode to update the current node from the - * selected block. This typically happens via the selection listener but that - * is not called immediately when `Gesture` calls - * `Blockly.common.setSelected`. In particular the listener runs after showing - * the context menu. - * - * @returns The current field, connection, or block the cursor is on. - */ - getCurNode(): IFocusableNode | null { - return getFocusManager().getFocusedNode(); - } - - /** - * Set the location of the cursor and draw it. - * - * Overrides normal Marker setCurNode logic to call - * this.drawMarker() instead of this.drawer.draw() directly. - * - * @param newNode The new location of the cursor. - */ - setCurNode(newNode: IFocusableNode) { - getFocusManager().focusNode(newNode); - } - - /** - * Get the first navigable node on the workspace, or null if none exist. - * - * @returns The first navigable node on the workspace, or null. - */ - getFirstNode(): IFocusableNode | null { - return this.workspace.getNavigator().getFirstChild(this.workspace); - } - - /** - * Get the last navigable node on the workspace, or null if none exist. - * - * @returns The last navigable node on the workspace, or null. - */ - getLastNode(): IFocusableNode | null { - const first = this.getFirstNode(); - return this.getPreviousNode(first, () => true, true); - } -} - -registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/packages/blockly/core/keyboard_nav/marker.ts b/packages/blockly/core/keyboard_nav/marker.ts deleted file mode 100644 index 0cd066c163c..00000000000 --- a/packages/blockly/core/keyboard_nav/marker.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a marker. - * Used primarily for keyboard navigation to show a marked location. - * - * @class - */ -// Former goog.module ID: Blockly.Marker - -import {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import {Icon} from '../icons/icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {RenderedConnection} from '../rendered_connection.js'; - -/** - * Class for a marker. - * This is used in keyboard navigation to save a location in the Blockly AST. - */ -export class Marker { - /** The colour of the marker. */ - colour: string | null = null; - - /** The current location of the marker. */ - protected curNode: IFocusableNode | null = null; - - /** The type of the marker. */ - type = 'marker'; - - /** - * Gets the current location of the marker. - * - * @returns The current field, connection, or block the marker is on. - */ - getCurNode(): IFocusableNode | null { - return this.curNode; - } - - /** - * Set the location of the marker and call the update method. - * - * @param newNode The new location of the marker, or null to remove it. - */ - setCurNode(newNode: IFocusableNode | null) { - this.curNode = newNode; - } - - /** Dispose of this marker. */ - dispose() { - this.curNode = null; - } - - /** - * Returns the block that the given node is a child of. - * - * @returns The parent block of the node if any, otherwise null. - */ - getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { - if (node instanceof BlockSvg) { - return node; - } else if (node instanceof Field) { - return node.getSourceBlock() as BlockSvg; - } else if (node instanceof RenderedConnection) { - return node.getSourceBlock(); - } else if (node instanceof Icon) { - return node.getSourceBlock() as BlockSvg; - } - - return null; - } - - /** - * Returns the block that this marker's current node is a child of. - * - * @returns The parent block of the marker's current node if any, otherwise - * null. - */ - getSourceBlock(): BlockSvg | null { - return this.getSourceBlockFromNode(this.getCurNode()); - } -} diff --git a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts similarity index 90% rename from packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts index f2f1ab7e107..fa471a8b342 100644 --- a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {TextInputBubble} from '../bubbles/textinput_bubble.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {TextInputBubble} from '../../bubbles/textinput_bubble.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an TextInputBubble. diff --git a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts similarity index 74% rename from packages/blockly/core/keyboard_nav/block_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts index 9f56b538455..3da4078f761 100644 --- a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts @@ -4,17 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {Field} from '../field.js'; -import type {Icon} from '../icons/icon.js'; -import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {isFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import type {ISelectable} from '../interfaces/i_selectable.js'; -import {RenderedConnection} from '../rendered_connection.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import type {Field} from '../../field.js'; +import type {Icon} from '../../icons/icon.js'; +import type {IBoundedElement} from '../../interfaces/i_bounded_element.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import type {ISelectable} from '../../interfaces/i_selectable.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {WorkspaceSvg} from '../../workspace_svg.js'; /** * Set of rules controlling keyboard navigation from a block. @@ -124,24 +124,48 @@ function getBlockNavigationCandidates( for (const input of block.inputList) { if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); - if (input.connection?.targetBlock()) { - const connectedBlock = input.connection.targetBlock() as BlockSvg; - if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + + const connection = input.connection as RenderedConnection | null; + if (!connection) continue; + + const connectedBlock = connection.targetBlock(); + if (connectedBlock) { + if (connection.type === ConnectionType.NEXT_STATEMENT && !forward) { const lastStackBlock = connectedBlock .lastConnectionInStack(false) ?.getSourceBlock(); if (lastStackBlock) { - candidates.push(lastStackBlock); + // When navigating backward, the last next connection in a stack in a + // statement input is navigable. + candidates.push(lastStackBlock.nextConnection); } } else { + // When navigating forward, a child block connected to a statement + // input is navigable. candidates.push(connectedBlock); } - } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { - candidates.push(input.connection as RenderedConnection); + } else if ( + connection.type === ConnectionType.INPUT_VALUE || + connection.type === ConnectionType.NEXT_STATEMENT + ) { + // Empty input or statement connections are navigable. + candidates.push(connection); } } + if ( + block.nextConnection && + !block.nextConnection.targetBlock() && + (block.lastConnectionInStack(true) !== block.nextConnection || + !!block.getSurroundParent()) + ) { + // The empty next connection on the last block in a stack inside of a + // statement input is navigable. + candidates.push(block.nextConnection); + } + return candidates; } @@ -157,7 +181,8 @@ function getBlockNavigationCandidates( * `delta` relative to the current element's stack when navigating backwards. */ export function navigateStacks(current: ISelectable, delta: number) { - const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + const workspace = current.workspace as WorkspaceSvg; + const stacks: IFocusableNode[] = workspace .getTopBoundedElements(true) .filter((element: IBoundedElement) => isFocusableNode(element)); const currentIndex = stacks.indexOf( @@ -174,9 +199,14 @@ export function navigateStacks(current: ISelectable, delta: number) { } // When navigating to a previous block stack, our previous sibling is the last - // block in it. + // block or nested next connection in it. if (delta < 0 && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + result = result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + + if (result instanceof BlockSvg && result.statementInputCount > 0) { + const candidates = getBlockNavigationCandidates(result, false); + result = candidates[candidates.length - 1] ?? result; + } } return result; diff --git a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts similarity index 92% rename from packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts index 6654d2d8fef..25d9b3eab05 100644 --- a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentBarButton} from '../comments/comment_bar_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentBarButton} from '../../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a CommentBarButton. diff --git a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts similarity index 85% rename from packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts index 456df8e97c8..3baaee3c51d 100644 --- a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentEditor} from '../comments/comment_editor.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentEditor} from '../../comments/comment_editor.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a comment editor. diff --git a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts similarity index 93% rename from packages/blockly/core/keyboard_nav/connection_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts index bf685d0635c..4e0caeb0ca7 100644 --- a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {RenderedConnection} from '../rendered_connection.js'; +import type {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; import {navigateBlock} from './block_navigation_policy.js'; /** diff --git a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts similarity index 90% rename from packages/blockly/core/keyboard_nav/field_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts index f9df406c22c..15af71c4ec9 100644 --- a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {BlockSvg} from '../../block_svg.js'; +import {Field} from '../../field.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** diff --git a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts similarity index 90% rename from packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts index 6c39c3061e7..5e5ccb86364 100644 --- a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutButton} from '../flyout_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutButton} from '../../flyout_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout button. diff --git a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_navigation_policy.ts similarity index 76% rename from packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_navigation_policy.ts index 6552c27b499..1e6e661c58a 100644 --- a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {IFlyout} from '../interfaces/i_flyout.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {IFlyout} from '../../interfaces/i_flyout.js'; +import {type IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Generic navigation policy that navigates between items in the flyout. @@ -26,11 +26,11 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { /** * Returns null to prevent navigating into flyout items. * - * @param _current The flyout item to navigate from. + * @param current The flyout item to navigate from. * @returns Null to prevent navigating into flyout items. */ - getFirstChild(_current: T): IFocusableNode | null { - return null; + getFirstChild(current: T): IFocusableNode | null { + return this.policy.getFirstChild(current); } /** @@ -60,6 +60,12 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index++; if (index >= flyoutContents.length) { + const loops = this.flyout + .getWorkspace() + .getNavigator() + .getNavigationLoops(); + if (!loops) return null; + index = 0; } @@ -83,6 +89,12 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index--; if (index < 0) { + const loops = this.flyout + .getWorkspace() + .getNavigator() + .getNavigationLoops(); + if (!loops) return null; + index = flyoutContents.length - 1; } @@ -96,7 +108,13 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { * @returns True if the given flyout item can be focused. */ isNavigable(current: T): boolean { - return this.policy.isNavigable(current); + return ( + this.policy.isNavigable(current) && + this.flyout + .getContents() + .map((item) => item.getElement()) + .includes(current as any) + ); } /** diff --git a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts similarity index 85% rename from packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts index eb7ca4eb783..1af70b27209 100644 --- a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutSeparator} from '../flyout_separator.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutSeparator} from '../../flyout_separator.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout separator. diff --git a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts similarity index 85% rename from packages/blockly/core/keyboard_nav/icon_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts index 112239d0655..78601eb5c93 100644 --- a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {BlockSvg} from '../block_svg.js'; -import {getFocusManager} from '../focus_manager.js'; -import {CommentIcon} from '../icons/comment_icon.js'; -import {Icon} from '../icons/icon.js'; -import {MutatorIcon} from '../icons/mutator_icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {BlockSvg} from '../../block_svg.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {CommentIcon} from '../../icons/comment_icon.js'; +import {Icon} from '../../icons/icon.js'; +import {MutatorIcon} from '../../icons/mutator_icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts new file mode 100644 index 00000000000..a8ffd128737 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {isCollapsibleToolboxItem} from '../../interfaces/i_collapsible_toolbox_item.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import { + isToolboxItem, + type IToolboxItem, +} from '../../interfaces/i_toolbox_item.js'; + +/** + * Set of rules controlling keyboard navigation from a toolbox item. + */ +export class ToolboxItemNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given toolbox item. + * + * @param current The toolbox item to return the first child of. + * @returns The child item of a collapsible toolbox item, or the flyout + * workspace for non-collapsible flyout items. + */ + getFirstChild(current: IToolboxItem): IFocusableNode | null { + if (isCollapsibleToolboxItem(current)) { + return current.getChildToolboxItems()[0]; + } + + return null; + } + + /** + * Returns the parent of the given toolbox item. + * + * @param current The toolbox item to return the parent of. + * @returns The parent toolbox item of the given toolbox item, if any. + */ + getParent(current: IToolboxItem): IFocusableNode | null { + return current.getParent(); + } + + /** + * Returns the next sibling of the given toolbox item. + * + * @param current The toolbox item to return the next sibling of. + * @returns The next toolbox item, or null. + */ + getNextSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index + 1] ?? null; + } + + /** + * Returns the previous sibling of the given toolbox item. + * + * @param current The toolbox item to return the previous sibling of. + * @returns The previous toolbox item, or null. + */ + getPreviousSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index - 1] ?? null; + } + + /** + * Returns whether or not the given toolbox item can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given toolbox item can be focused. + */ + isNavigable(current: IToolboxItem): boolean { + return current.canBeFocused() && this.allParentsExpanded(current); + } + + private allParentsExpanded(current: IToolboxItem): boolean { + const parent = current.getParent(); + if (!parent || !isCollapsibleToolboxItem(parent)) return true; + + return parent.isExpanded() && this.allParentsExpanded(parent); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an IToolboxItem. + */ + isApplicable(current: any): current is IToolboxItem { + return isToolboxItem(current); + } +} diff --git a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts similarity index 90% rename from packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts index 7fe70ceadef..96afb6c9743 100644 --- a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {RenderedWorkspaceComment} from '../../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateStacks} from './block_navigation_policy.js'; /** diff --git a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts similarity index 90% rename from packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts index b671f8fe739..38efb6207e1 100644 --- a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../../workspace_svg.js'; /** * Set of rules controlling keyboard navigation from a workspace. diff --git a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts new file mode 100644 index 00000000000..47e7648a6f5 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IFocusableNode} from '../../blockly.js'; +import type {IFlyout} from '../../interfaces/i_flyout.js'; +import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js'; +import {FlyoutNavigationPolicy} from '../navigation_policies/flyout_navigation_policy.js'; +import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js'; +import {NavigationDirection, Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a flyout. + */ +export class FlyoutNavigator extends Navigator { + constructor(protected flyout: IFlyout) { + super(); + this.rules.push( + new FlyoutButtonNavigationPolicy(), + new FlyoutSeparatorNavigationPolicy(), + ); + this.rules = this.rules.map( + (rule) => new FlyoutNavigationPolicy(rule, flyout), + ); + } + + /** + * Returns the toolbox when navigating to the left in a flyout. + */ + override getOutNode(): IFocusableNode | null { + const toolbox = this.flyout.targetWorkspace?.getToolbox(); + if (toolbox) return toolbox.getSelectedItem(); + + return null; + } + + /** + * Returns a function used to validate navigation candidates. Always allows + * up/down navigation, never allows left/right. + */ + override getValidationFunction(direction: NavigationDirection) { + if ( + direction === NavigationDirection.NEXT || + direction === NavigationDirection.PREVIOUS + ) { + return () => true; + } else { + return () => false; + } + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts new file mode 100644 index 00000000000..c18700164fc --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -0,0 +1,498 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../../block_svg.js'; +import {RenderedWorkspaceComment} from '../../comments/rendered_workspace_comment.js'; +import {ConnectionType} from '../../connection_type.js'; +import {Field} from '../../field.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {Icon} from '../../icons/icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {BlockCommentNavigationPolicy} from '../navigation_policies/block_comment_navigation_policy.js'; +import {BlockNavigationPolicy} from '../navigation_policies/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from '../navigation_policies/comment_bar_button_navigation_policy.js'; +import {CommentEditorNavigationPolicy} from '../navigation_policies/comment_editor_navigation_policy.js'; +import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navigation_policy.js'; +import {FieldNavigationPolicy} from '../navigation_policies/field_navigation_policy.js'; +import {IconNavigationPolicy} from '../navigation_policies/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from '../navigation_policies/workspace_comment_navigation_policy.js'; +import {WorkspaceNavigationPolicy} from '../navigation_policies/workspace_navigation_policy.js'; + +type RuleList = INavigationPolicy[]; + +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + +/** + * Class responsible for determining where focus should move in response to + * keyboard navigation commands. + */ +export class Navigator { + /** + * Map from classes to a corresponding ruleset to handle navigation from + * instances of that class. + */ + protected rules: RuleList = [ + new BlockNavigationPolicy(), + new FieldNavigationPolicy(), + new ConnectionNavigationPolicy(), + new WorkspaceNavigationPolicy(), + new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), + new BlockCommentNavigationPolicy(), + new CommentEditorNavigationPolicy(), + ]; + + /** Whether or not navigation loops around when reaching the end. */ + protected navigationLoops = false; + + protected relativeNode: IFocusableNode | null = null; + + /** + * Adds a navigation ruleset to this Navigator. + * + * @param policy A ruleset that determines where focus should move starting + * from an instance of its managed class. + */ + addNavigationPolicy(policy: INavigationPolicy) { + this.rules.push(policy); + } + + /** + * Returns the navigation ruleset associated with the given object instance's + * class. + * + * @param current An object to retrieve a navigation ruleset for. + * @returns The navigation ruleset of objects of the given object's class, or + * undefined if no ruleset has been registered for the object's class. + */ + private get( + current: IFocusableNode, + ): INavigationPolicy | undefined { + return this.rules.find((rule) => rule.isApplicable(current)); + } + + /** + * Returns the first child of the given object instance, if any. + * + * @param current The object to retrieve the first child of. + * @returns The first child node of the given object, if any. + */ + getFirstChild(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getFirstChild(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) { + return this.getFirstChild(result) || this.getNextSibling(result); + } + return result; + } + + /** + * Returns the parent of the given object instance, if any. + * + * @param current The object to retrieve the parent of. + * @returns The parent node of the given object, if any. + */ + getParent(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getParent(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) return this.getParent(result); + return result; + } + + /** + * Returns the next sibling of the given object instance, if any. + * + * @param current The object to retrieve the next sibling node of. + * @returns The next sibling node of the given object, if any. + */ + getNextSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getNextSibling(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) { + return this.getNextSibling(result); + } + return result; + } + + /** + * Returns the previous sibling of the given object instance, if any. + * + * @param current The object to retrieve the previous sibling node of. + * @returns The previous sibling node of the given object, if any. + */ + getPreviousSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getPreviousSibling(current); + if (!result) return null; + if (!this.get(result)?.isNavigable(result)) { + return this.getPreviousSibling(result); + } + return result; + } + + /** + * Returns the previous node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The previous node, generally on the "row" visually above the + * specified node, or null if there is none. + */ + getPreviousNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + this.relativeNode = node; + return this.getPreviousNodeImpl(node, NavigationDirection.PREVIOUS); + } + + /** + * Returns the node to the left of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the left of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getOutNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + this.relativeNode = node; + return this.getPreviousNodeImpl(node, NavigationDirection.OUT); + } + + /** + * Returns next node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The next node, generally on the "row" visually below the + * specified node, or null if there is none. + */ + getNextNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + this.relativeNode = node; + return this.getNextNodeImpl(node, NavigationDirection.NEXT); + } + + /** + * Returns the node to the right of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the right of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getInNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + this.relativeNode = node; + return this.getNextNodeImpl(node, NavigationDirection.IN); + } + + /** + * Returns the previous sibling/parent node relative to the given node. + * + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either OUT or PREVIOUS. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The previous sibling/parent node, or null if there is none or a + * node was not provided. + */ + private getPreviousNodeImpl( + node: IFocusableNode | null, + direction: NavigationDirection.PREVIOUS | NavigationDirection.OUT, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if ( + !node || + visitedNodes.has(node) || + (!this.getNavigationLoops() && node === this.getFirstNode()) + ) { + return null; + } + + const newNode = + this.getRightMostChild(this.getPreviousSibling(node), node) || + this.getParent(node); + + const isValid = this.getValidationFunction(direction); + if (newNode && isValid(newNode)) return newNode; + if (newNode) { + visitedNodes.add(node); + return this.getPreviousNodeImpl(newNode, direction, visitedNodes); + } + return null; + } + + /** + * Returns the next sibling/child node relative to the given node. + * + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either IN or NEXT. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The next sibling/child node, or null if there is none or a + * node was not provided. + */ + private getNextNodeImpl( + node: IFocusableNode | null, + direction: NavigationDirection.NEXT | NavigationDirection.IN, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || visitedNodes.has(node)) { + return null; + } + + let newNode = this.getFirstChild(node) || this.getNextSibling(node); + + let target = node; + while (target && !newNode) { + const parent = this.getParent(target); + if (!parent) break; + newNode = this.getNextSibling(parent); + if (newNode === this.getFirstNode()) return null; + target = parent; + } + + const isValid = this.getValidationFunction(direction); + if (newNode && isValid(newNode)) { + return newNode; + } + if (newNode) { + visitedNodes.add(node); + return this.getNextNodeImpl(newNode, direction, visitedNodes); + } + + return null; + } + + private getRightMostChild( + node: IFocusableNode | null, + stopIfFound?: IFocusableNode, + ): IFocusableNode | null { + if (!node) return node; + let newNode = this.getFirstChild(node); + if (!newNode || newNode === stopIfFound) return node; + for ( + let nextNode: IFocusableNode | null = newNode; + nextNode; + nextNode = this.getNextSibling(newNode) + ) { + if (nextNode === stopIfFound) break; + newNode = nextNode; + } + return this.getRightMostChild(newNode, stopIfFound); + } + + /** + * Sets whether or not navigation should loop around when reaching the end + * of the workspace. + * + * @param loops True if navigation should loop around, otherwise false. + */ + setNavigationLoops(loops: boolean) { + this.navigationLoops = loops; + } + + /** + * Returns whether or not navigation loops around when reaching the end of + * the workspace. + */ + getNavigationLoops(): boolean { + return this.navigationLoops; + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): IFocusableNode | null { + const root = getFocusManager().getFocusedTree()?.getRootFocusableNode(); + if (!root) return null; + + return this.getFirstChild(root); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): IFocusableNode | null { + const first = this.getFirstNode(); + const oldLooping = this.getNavigationLoops(); + this.setNavigationLoops(true); + const lastNode = this.getPreviousNode(first); + this.setNavigationLoops(oldLooping); + return lastNode; + } + + /** + * Returns a function that will be used to determine whether a candidate for + * navigation is valid. + * + * @param direction The direction in which the user is navigating. + * @returns A function that takes a proposed navigation candidate and returns + * true if navigation should be allowed to proceed to it, or false to find + * a different candidate. + */ + getValidationFunction( + direction: NavigationDirection, + ): (node: IFocusableNode) => boolean { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return (candidate: IFocusableNode) => { + const candidateBlock = this.getSourceBlockFromNode(candidate); + const currentBlock = this.getSourceBlockFromNode(this.relativeNode); + + // Preventing escaping the current block/comment/etc by: + // Disallow moving from a node with a block to a non-block node (other than a block comment editor) + // Disallow moving from a non-block node to a block node + // Disallow moving to the workspace + if ( + (currentBlock && !candidateBlock) || + (!currentBlock && candidateBlock) || + (candidate as unknown) === this.relativeNode?.getFocusableTree() + ) { + return false; + } + + if (!candidateBlock || !currentBlock) return true; + + const currentParents = currentBlock.getOutputParents(); + const candidateParents = candidateBlock.getOutputParents(); + // If we're navigating from a block (or nested element) to a block + // (or nested element), ensure that we're not crossing a statement + // block boundary (i.e. moving to a next or previous block vertically) + // by verifying that the two blocks in question are either the same + // or have a common parent accessible only by traversing output + // connections, meaning that they are part of the same row. + return ( + (candidateParents as any).intersection(currentParents).size > 0 + ); + }; + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return (candidate: IFocusableNode | null) => { + if ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment || + (candidate instanceof RenderedConnection && + (candidate.type === ConnectionType.NEXT_STATEMENT || + (candidate.type === ConnectionType.INPUT_VALUE && + candidate.getSourceBlock().statementInputCount && + candidate.getSourceBlock().inputList[0] !== + candidate.getParentInput()))) + ) { + return true; + } + + const currentNode = this.relativeNode; + if (direction === NavigationDirection.PREVIOUS) { + // Don't visit rightmost/nested blocks in statement blocks when + // navigating to the previous block. + if ( + currentNode instanceof RenderedConnection && + currentNode.type === ConnectionType.NEXT_STATEMENT && + !currentNode.getParentInput() && + candidate !== currentNode.getSourceBlock() + ) { + return false; + } + + // Don't visit the first value/input block in a block with statement + // inputs when navigating to the previous block. This is consistent + // with the behavior when navigating to the next block and avoids + // duplicative screen reader narration. Also don't visit value + // blocks nested in non-statement inputs. + if ( + candidate instanceof BlockSvg && + candidate.outputConnection?.targetConnection + ) { + const parentInput = + candidate.outputConnection.targetConnection.getParentInput(); + if ( + !parentInput?.getSourceBlock().statementInputCount || + parentInput?.getSourceBlock().inputList[0] === parentInput + ) { + return false; + } + } + } + + const currentBlock = this.getSourceBlockFromNode(currentNode); + if ( + candidate instanceof BlockSvg && + currentBlock instanceof BlockSvg + ) { + // If the candidate's parent uses inline inputs, disallow the + // candidate; it follows that it must be on the same row as its + // parent. + if (candidate.outputConnection?.targetBlock()?.getInputsInline()) { + return false; + } + + const candidateParents = candidate.getParents(); + // If the candidate block is an (in)direct child of the current + // block, disallow it; it cannot be on a different row than the + // current block. + if ( + currentBlock === this.relativeNode && + candidateParents.has(currentBlock) + ) { + return false; + } + + const currentParents = currentBlock.getParents(); + + const sharedParents = (currentParents as any).intersection( + candidateParents, + ); + // Allow the candidate if it and the current block have no parents + // in common, or if they have a shared parent with external inputs. + const result = + !sharedParents.size || + sharedParents + .values() + .some((block: BlockSvg) => !block.getInputsInline()); + return result; + } + + return false; + }; + } + } + + /** + * Returns the block that the given node is a child of. + * + * @returns The parent block of the node if any, otherwise null. + */ + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { + if (node instanceof BlockSvg) { + return node; + } else if (node instanceof Field) { + return node.getSourceBlock() as BlockSvg; + } else if (node instanceof RenderedConnection) { + return node.getSourceBlock(); + } else if (node instanceof Icon) { + return node.getSourceBlock() as BlockSvg; + } + + return null; + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts new file mode 100644 index 00000000000..25cba8003e1 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {getFocusManager} from '../../focus_manager.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import {isSelectableToolboxItem} from '../../interfaces/i_selectable_toolbox_item.js'; +import type {IToolbox} from '../../interfaces/i_toolbox.js'; +import {ToolboxItemNavigationPolicy} from '../navigation_policies/toolbox_item_navigation_policy.js'; +import {NavigationDirection, Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a toolbox. + */ +export class ToolboxNavigator extends Navigator { + constructor(protected toolbox: IToolbox) { + super(); + this.rules = [new ToolboxItemNavigationPolicy()]; + } + + /** + * Returns the flyout's first item when navigating to the right in a toolbox + * from a toolbox item that has a flyout. + */ + getInNode( + current = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + if (isSelectableToolboxItem(current) && !current.getContents().length) { + return null; + } + + return ( + this.toolbox.getFlyout()?.getWorkspace().getRestoredFocusableNode(null) ?? + null + ); + } + + /** + * Returns a function used to validate navigation candidates. Always allows + * up/down navigation, never allows left/right. + */ + override getValidationFunction(direction: NavigationDirection) { + if ( + direction === NavigationDirection.PREVIOUS || + direction === NavigationDirection.NEXT + ) { + return () => true; + } else { + return () => false; + } + } +} diff --git a/packages/blockly/core/marker_manager.ts b/packages/blockly/core/marker_manager.ts deleted file mode 100644 index e94aa3e966a..00000000000 --- a/packages/blockly/core/marker_manager.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Object in charge of managing markers and the cursor. - * - * @class - */ -// Former goog.module ID: Blockly.MarkerManager - -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; - -/** - * Class to manage the multiple markers and the cursor on a workspace. - */ -export class MarkerManager { - /** The name of the local marker. */ - static readonly LOCAL_MARKER = 'local_marker_1'; - - /** The cursor. */ - private cursor: LineCursor; - - /** The map of markers for the workspace. */ - private markers = new Map(); - - /** - * @param workspace The workspace for the marker manager. - * @internal - */ - constructor(private readonly workspace: WorkspaceSvg) { - this.cursor = new LineCursor(this.workspace); - } - - /** - * Register the marker by adding it to the map of markers. - * - * @param id A unique identifier for the marker. - * @param marker The marker to register. - */ - registerMarker(id: string, marker: Marker) { - if (this.markers.has(id)) { - this.unregisterMarker(id); - } - this.markers.set(id, marker); - } - - /** - * Unregister the marker by removing it from the map of markers. - * - * @param id The ID of the marker to unregister. - */ - unregisterMarker(id: string) { - const marker = this.markers.get(id); - if (marker) { - marker.dispose(); - this.markers.delete(id); - } else { - throw Error( - 'Marker with ID ' + - id + - ' does not exist. ' + - 'Can only unregister markers that exist.', - ); - } - } - - /** - * Get the cursor for the workspace. - * - * @returns The cursor for this workspace. - */ - getCursor(): LineCursor { - return this.cursor; - } - - /** - * Get a single marker that corresponds to the given ID. - * - * @param id A unique identifier for the marker. - * @returns The marker that corresponds to the given ID, or null if none - * exists. - */ - getMarker(id: string): Marker | null { - return this.markers.get(id) || null; - } - - /** - * Sets the cursor and initializes the drawer for use with keyboard - * navigation. - * - * @param cursor The cursor used to move around this workspace. - */ - setCursor(cursor: LineCursor) { - this.cursor = cursor; - } - - /** - * Dispose of the marker manager. - * Go through and delete all markers associated with this marker manager. - * - * @internal - */ - dispose() { - const markerIds = Object.keys(this.markers); - for (let i = 0, markerId; (markerId = markerIds[i]); i++) { - this.unregisterMarker(markerId); - } - this.markers.clear(); - this.cursor.dispose(); - } -} diff --git a/packages/blockly/core/navigator.ts b/packages/blockly/core/navigator.ts deleted file mode 100644 index 9c7c22f5987..00000000000 --- a/packages/blockly/core/navigator.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; -import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js'; -import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; -import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; -import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js'; -import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; -import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; -import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; -import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; -import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; - -type RuleList = INavigationPolicy[]; - -/** - * Class responsible for determining where focus should move in response to - * keyboard navigation commands. - */ -export class Navigator { - /** - * Map from classes to a corresponding ruleset to handle navigation from - * instances of that class. - */ - protected rules: RuleList = [ - new BlockNavigationPolicy(), - new FieldNavigationPolicy(), - new ConnectionNavigationPolicy(), - new WorkspaceNavigationPolicy(), - new IconNavigationPolicy(), - new WorkspaceCommentNavigationPolicy(), - new CommentBarButtonNavigationPolicy(), - new BlockCommentNavigationPolicy(), - new CommentEditorNavigationPolicy(), - ]; - - /** - * Adds a navigation ruleset to this Navigator. - * - * @param policy A ruleset that determines where focus should move starting - * from an instance of its managed class. - */ - addNavigationPolicy(policy: INavigationPolicy) { - this.rules.push(policy); - } - - /** - * Returns the navigation ruleset associated with the given object instance's - * class. - * - * @param current An object to retrieve a navigation ruleset for. - * @returns The navigation ruleset of objects of the given object's class, or - * undefined if no ruleset has been registered for the object's class. - */ - private get( - current: IFocusableNode, - ): INavigationPolicy | undefined { - return this.rules.find((rule) => rule.isApplicable(current)); - } - - /** - * Returns the first child of the given object instance, if any. - * - * @param current The object to retrieve the first child of. - * @returns The first child node of the given object, if any. - */ - getFirstChild(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getFirstChild(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getFirstChild(result) || this.getNextSibling(result); - } - return result; - } - - /** - * Returns the parent of the given object instance, if any. - * - * @param current The object to retrieve the parent of. - * @returns The parent node of the given object, if any. - */ - getParent(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getParent(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) return this.getParent(result); - return result; - } - - /** - * Returns the next sibling of the given object instance, if any. - * - * @param current The object to retrieve the next sibling node of. - * @returns The next sibling node of the given object, if any. - */ - getNextSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getNextSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getNextSibling(result); - } - return result; - } - - /** - * Returns the previous sibling of the given object instance, if any. - * - * @param current The object to retrieve the previous sibling node of. - * @returns The previous sibling node of the given object, if any. - */ - getPreviousSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getPreviousSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getPreviousSibling(result); - } - return result; - } -} diff --git a/packages/blockly/core/registry.ts b/packages/blockly/core/registry.ts index 4980a559478..d851d33f2c0 100644 --- a/packages/blockly/core/registry.ts +++ b/packages/blockly/core/registry.ts @@ -26,7 +26,6 @@ import type { IVariableModelStatic, IVariableState, } from './interfaces/i_variable_model.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Options} from './options.js'; import type {Renderer} from './renderers/common/renderer.js'; import type {Theme} from './theme.js'; @@ -78,8 +77,6 @@ export class Type<_T> { 'connectionPreviewer', ); - static CURSOR = new Type('cursor'); - static EVENT = new Type('event'); static FIELD = new Type('field'); diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index de13f0788c1..60234e04981 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -39,6 +39,7 @@ export enum names { REDO = 'redo', MENU = 'menu', FOCUS_WORKSPACE = 'focus_workspace', + FOCUS_TOOLBOX = 'focus_toolbox', START_MOVE = 'start_move', START_MOVE_STACK = 'start_move_stack', FINISH_MOVE = 'finish_move', @@ -47,6 +48,10 @@ export enum names { MOVE_DOWN = 'move_down', MOVE_LEFT = 'move_left', MOVE_RIGHT = 'move_right', + NAVIGATE_RIGHT = 'right', + NAVIGATE_LEFT = 'left', + NAVIGATE_UP = 'up', + NAVIGATE_DOWN = 'down', } /** @@ -395,7 +400,11 @@ export function registerMovementShortcuts() { ): IDraggable | undefined => { const node = getFocusManager().getFocusedNode(); if (isDraggable(node)) return node; - return workspace.getCursor().getSourceBlock() ?? undefined; + return ( + workspace + .getNavigator() + .getSourceBlockFromNode(getFocusManager().getFocusedNode()) ?? undefined + ); }; const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [ @@ -527,7 +536,9 @@ export function registerShowContextMenu() { preconditionFn: (workspace) => { return !workspace.isDragging(); }, - callback: (_workspace, e) => { + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); + workspace.keyboardAccessibilityMode = true; const target = getFocusManager().getFocusedNode(); if (hasContextMenu(target)) { target.showContextMenu(e); @@ -542,6 +553,88 @@ export function registerShowContextMenu() { ShortcutRegistry.registry.register(contextMenuShortcut); } +/** + * Registers keyboard shortcuts to navigate around the Blockly interface. + */ +export function registerArrowNavigation() { + const shortcuts: { + [name: string]: ShortcutRegistry.KeyboardShortcut; + } = { + /** Go to the next location to the right. */ + right: { + name: names.NAVIGATE_RIGHT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getOutNode() + : getFocusManager().getFocusedTree()?.getNavigator().getInNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.RIGHT], + allowCollision: true, + }, + + /** Go to the next location to the left. */ + left: { + name: names.NAVIGATE_LEFT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getInNode() + : getFocusManager().getFocusedTree()?.getNavigator().getOutNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.LEFT], + allowCollision: true, + }, + + /** Go down to the next location. */ + down: { + name: names.NAVIGATE_DOWN, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getNextNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.DOWN], + allowCollision: true, + }, + /** Go up to the previous location. */ + up: { + name: names.NAVIGATE_UP, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getPreviousNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.UP], + allowCollision: true, + }, + }; + + for (const shortcut of Object.values(shortcuts)) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Registers keyboard shortcut to focus the workspace. */ @@ -556,7 +649,7 @@ export function registerFocusWorkspace() { return workspace.getRootWorkspace() ?? workspace; }; - const contextMenuShortcut: KeyboardShortcut = { + const focusWorkspaceShortcut: KeyboardShortcut = { name: names.FOCUS_WORKSPACE, preconditionFn: (workspace) => !workspace.isDragging(), callback: (workspace) => { @@ -566,7 +659,34 @@ export function registerFocusWorkspace() { }, keyCodes: [KeyCodes.W], }; - ShortcutRegistry.registry.register(contextMenuShortcut); + ShortcutRegistry.registry.register(focusWorkspaceShortcut); +} + +/** + * Registers keyboard shortcut to focus the toolbox. + */ +export function registerFocusToolbox() { + const focusToolboxShortcut: KeyboardShortcut = { + name: names.FOCUS_TOOLBOX, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + const toolbox = workspace.getToolbox(); + if (toolbox) { + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(toolbox); + return true; + } else { + const flyout = workspace.getFlyout(); + if (!flyout) return false; + + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(flyout.getWorkspace()); + return true; + } + }, + keyCodes: [KeyCodes.T], + }; + ShortcutRegistry.registry.register(focusToolboxShortcut); } /** @@ -593,6 +713,8 @@ export function registerKeyboardNavigationShortcuts() { registerShowContextMenu(); registerMovementShortcuts(); registerFocusWorkspace(); + registerFocusToolbox(); + registerArrowNavigation(); } registerDefaultShortcuts(); diff --git a/packages/blockly/core/toolbox/category.ts b/packages/blockly/core/toolbox/category.ts index dd42a549f69..72acdee730f 100644 --- a/packages/blockly/core/toolbox/category.ts +++ b/packages/blockly/core/toolbox/category.ts @@ -593,6 +593,16 @@ export class ToolboxCategory return this.htmlDiv_; } + /** + * Handles this toolbox category gaining focus by informing its parent + * toolbox that it has been selected. + */ + override onNodeFocus(): void { + if (this.getParentToolbox().getSelectedItem() !== this) { + this.getParentToolbox().setSelectedItem(this); + } + } + /** * Gets the contents of the category. These are items that are meant to be * displayed in the flyout. diff --git a/packages/blockly/core/toolbox/separator.ts b/packages/blockly/core/toolbox/separator.ts index cd5ed245a04..bcf66a16b8e 100644 --- a/packages/blockly/core/toolbox/separator.ts +++ b/packages/blockly/core/toolbox/separator.ts @@ -73,6 +73,13 @@ export class ToolboxSeparator extends ToolboxItem { override dispose() { dom.removeNode(this.htmlDiv as HTMLDivElement); } + + /** + * Prevents separator toolbox items from gaining focus. + */ + override canBeFocused(): boolean { + return false; + } } export namespace ToolboxSeparator { diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index 6f4daf4ed71..28861f231f9 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -23,7 +23,10 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; import {type IAutoHideable} from '../interfaces/i_autohideable.js'; -import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; +import { + isCollapsibleToolboxItem, + type ICollapsibleToolboxItem, +} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; @@ -35,6 +38,7 @@ import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.j import type {IStyleable} from '../interfaces/i_styleable.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; +import {ToolboxNavigator} from '../keyboard_nav/navigators/toolbox_navigator.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; @@ -111,6 +115,9 @@ export class Toolbox /** Whether the mouse is currently being clicked. */ private mouseDown = false; + /** Object used by keyboard navigation to move focus in this toolbox. */ + private navigator = new ToolboxNavigator(this); + /** @param workspace The workspace in which to create new blocks. */ constructor(workspace: WorkspaceSvg) { super(); @@ -300,40 +307,17 @@ export class Toolbox protected onKeyDown_(e: KeyboardEvent) { let handled = false; switch (e.key) { - case 'ArrowDown': - handled = this.selectNext(); - break; - case 'ArrowUp': - handled = this.selectPrevious(); - break; case 'ArrowLeft': - handled = this.selectParent(); + handled = this.toggleSelectedItem(false); break; case 'ArrowRight': - handled = this.selectChild(); - break; - case 'Enter': - case ' ': - if (this.selectedItem_ && this.selectedItem_.isCollapsible()) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - handled = true; - } - break; - default: - handled = false; + handled = this.toggleSelectedItem(true); break; } - if (!handled && this.selectedItem_) { - // TODO(#6097): Figure out who implements onKeyDown and which interface it - // should be part of. - if ((this.selectedItem_ as any).onKeyDown) { - handled = (this.selectedItem_ as any).onKeyDown(e); - } - } if (handled) { e.preventDefault(); + e.stopPropagation(); } } @@ -976,99 +960,21 @@ export class Toolbox } /** - * Closes the current item if it is expanded, or selects the parent. + * Sets the currently selected item's expansion state, if possible. * - * @returns True if a parent category was selected, false otherwise. + * @param expanded True to expand the item or false to collapse it. + * @returns True if the selected item's expansion state was updated. */ - private selectParent(): boolean { - if (!this.selectedItem_) { - return false; - } - + private toggleSelectedItem(expanded: boolean): boolean { if ( + isCollapsibleToolboxItem(this.selectedItem_) && this.selectedItem_.isCollapsible() && - (this.selectedItem_ as ICollapsibleToolboxItem).isExpanded() - ) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - return true; - } else if ( - this.selectedItem_.getParent() && - this.selectedItem_.getParent()!.isSelectable() + this.selectedItem_.isExpanded() !== expanded ) { - this.setSelectedItem(this.selectedItem_.getParent()); + this.selectedItem_.toggleExpanded(); return true; } - return false; - } - - /** - * Selects the first child of the currently selected item, or nothing if the - * toolbox item has no children. - * - * @returns True if a child category was selected, false otherwise. - */ - private selectChild(): boolean { - if (!this.selectedItem_ || !this.selectedItem_.isCollapsible()) { - return false; - } - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - if (!collapsibleItem.isExpanded()) { - collapsibleItem.toggleExpanded(); - return true; - } else { - this.selectNext(); - return true; - } - } - /** - * Selects the next visible toolbox item. - * - * @returns True if a next category was selected, false otherwise. - */ - private selectNext(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let nextItemIdx = items.indexOf(this.selectedItem_) + 1; - if (nextItemIdx > -1 && nextItemIdx < items.length) { - let nextItem = items[nextItemIdx]; - while (nextItem && !nextItem.isSelectable()) { - nextItem = items[++nextItemIdx]; - } - if (nextItem && nextItem.isSelectable()) { - this.setSelectedItem(nextItem); - return true; - } - } - return false; - } - - /** - * Selects the previous visible toolbox item. - * - * @returns True if a previous category was selected, false otherwise. - */ - private selectPrevious(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let prevItemIdx = items.indexOf(this.selectedItem_) - 1; - if (prevItemIdx > -1 && prevItemIdx < items.length) { - let prevItem = items[prevItemIdx]; - while (prevItem && !prevItem.isSelectable()) { - prevItem = items[--prevItemIdx]; - } - if (prevItem && prevItem.isSelectable()) { - this.setSelectedItem(prevItem); - return true; - } - } return false; } @@ -1167,6 +1073,14 @@ export class Toolbox this.autoHide(false); } } + + /** + * Returns the Navigator instance to use to move between items in this + * toolbox. + */ + getNavigator(): ToolboxNavigator { + return this.navigator; + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/packages/blockly/core/toolbox/toolbox_item.ts b/packages/blockly/core/toolbox/toolbox_item.ts index 9fc5c160ddc..92c3721363a 100644 --- a/packages/blockly/core/toolbox/toolbox_item.ts +++ b/packages/blockly/core/toolbox/toolbox_item.ts @@ -177,5 +177,12 @@ export class ToolboxItem implements IToolboxItem { canBeFocused(): boolean { return true; } + + /** + * Returns the toolbox this toolbox item belongs to. + */ + getParentToolbox(): IToolbox { + return this.parentToolbox_; + } } // nop by default diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 657a94d463c..4dfb646da89 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -60,12 +60,9 @@ import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; +import {Navigator} from './keyboard_nav/navigators/navigator.js'; import {LayerManager} from './layer_manager.js'; -import {MarkerManager} from './marker_manager.js'; import {Msg} from './msg.js'; -import {Navigator} from './navigator.js'; import {Options} from './options.js'; import * as Procedures from './procedures.js'; import * as registry from './registry.js'; @@ -296,7 +293,6 @@ export class WorkspaceSvg private readonly highlightedBlocks: BlockSvg[] = []; private audioManager: WorkspaceAudio; private grid: Grid | null; - private markerManager: MarkerManager; /** * Map from function names to callbacks, for deciding what to do when a @@ -384,9 +380,6 @@ export class WorkspaceSvg ? new Grid(this.options.gridPattern, options.gridOptions) : null; - /** Manager in charge of markers and cursors. */ - this.markerManager = new MarkerManager(this); - if (Variables && Variables.internalFlyoutCategory) { this.registerToolboxCategoryCallback( Variables.CATEGORY_NAME, @@ -432,15 +425,6 @@ export class WorkspaceSvg this.cachedParentSvgSize = new Size(0, 0); } - /** - * Get the marker manager for this workspace. - * - * @returns The marker manager. - */ - getMarkerManager(): MarkerManager { - return this.markerManager; - } - /** * Gets the metrics manager for this workspace. * @@ -470,27 +454,6 @@ export class WorkspaceSvg return this.componentManager; } - /** - * Get the marker with the given ID. - * - * @param id The ID of the marker. - * @returns The marker with the given ID or null if no marker with the given - * ID exists. - * @internal - */ - getMarker(id: string): Marker | null { - return this.markerManager.getMarker(id); - } - - /** - * The cursor for this workspace. - * - * @returns The cursor for the workspace. - */ - getCursor(): LineCursor { - return this.markerManager.getCursor(); - } - /** * Get the block renderer attached to this workspace. * @@ -834,12 +797,6 @@ export class WorkspaceSvg this.grid.update(this.scale); } this.recordDragTargets(); - const CursorClass = registry.getClassFromOptions( - registry.Type.CURSOR, - this.options, - ); - - if (CursorClass) this.markerManager.setCursor(new CursorClass(this)); const isParentWorkspace = this.options.parentWorkspace === null; this.renderer.createDom( @@ -896,7 +853,6 @@ export class WorkspaceSvg } this.renderer.dispose(); - this.markerManager.dispose(); super.dispose(); diff --git a/packages/blockly/tests/mocha/cursor_test.js b/packages/blockly/tests/mocha/cursor_test.js deleted file mode 100644 index 02426ae26b8..00000000000 --- a/packages/blockly/tests/mocha/cursor_test.js +++ /dev/null @@ -1,922 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {assert} from '../../node_modules/chai/index.js'; -import {createRenderedBlock} from './test_helpers/block_definitions.js'; -import { - sharedTestSetup, - sharedTestTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('Cursor', function () { - suite('Movement', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'input_statement', - 'message0': '%1 %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME1', - 'text': 'default', - }, - { - 'type': 'field_input', - 'name': 'NAME2', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME3', - }, - { - 'type': 'input_statement', - 'name': 'NAME4', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'field_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'output': null, - 'colour': 230, - '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(); - const blockA = createRenderedBlock(this.workspace, 'input_statement'); - const blockB = createRenderedBlock(this.workspace, 'input_statement'); - const blockC = createRenderedBlock(this.workspace, 'input_statement'); - const blockD = createRenderedBlock(this.workspace, 'input_statement'); - const blockE = createRenderedBlock(this.workspace, 'field_input'); - - blockA.nextConnection.connect(blockB.previousConnection); - blockA.inputList[0].connection.connect(blockE.outputConnection); - blockB.inputList[1].connection.connect(blockC.previousConnection); - this.cursor.drawer = null; - this.blocks = { - A: blockA, - B: blockB, - C: blockC, - D: blockD, - E: blockE, - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - - test('Next - From a Previous connection go to the next block', function () { - const prevNode = this.blocks.A.previousConnection; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); - }); - test('Next - From a block go to its statement input', function () { - const prevNode = this.blocks.B; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.C); - }); - - test('In - From field to attached input connection', function () { - const fieldBlock = this.blocks.E; - const fieldNode = this.blocks.A.getField('NAME2'); - this.cursor.setCurNode(fieldNode); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, fieldBlock); - }); - - test('Prev - From previous connection does skip over next connection', function () { - const prevConnection = this.blocks.B.previousConnection; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.prev(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); - }); - - test('Prev - From first block loop to last block', function () { - const prevConnection = this.blocks.A; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.prev(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D); - }); - - test('Out - From field does not skip over block node', function () { - const field = this.blocks.E.inputList[0].fieldRow[0]; - const fieldNode = field; - this.cursor.setCurNode(fieldNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.E); - }); - - test('Out - From first connection loop to last next connection', function () { - const prevConnection = this.blocks.A.previousConnection; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - 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); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '', - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - { - 'type': 'statement_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'c_hat_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('one empty block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one stack block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one row block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('row_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA.inputList[0].connection); - }); - }); - suite('one c-hat block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('multiblock 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', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB); - }); - }); - - suite('multiblock row', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'row_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'inputs': { - 'INPUT': { - 'block': { - 'type': 'row_block', - 'id': 'B', - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB.inputList[0].connection); - }); - }); - - suite('two stacks', 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', - }, - }, - }, - { - 'type': 'stack_block', - 'id': 'C', - 'x': 100, - 'y': 100, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'D', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const location = node; - const blockA = this.workspace.getBlockById('A'); - assert.equal(location, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const location = node; - const blockD = this.workspace.getBlockById('D'); - assert.equal(location, blockD); - }); - }); - }); - 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.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - 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 = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockA); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockB.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockB); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockC); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end - with loopback', function () { - const startNode = 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 = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(nextNode, this.blockA.previousConnection); - }); - - test('Valid if block - start at end - with loopback', function () { - const startNode = this.blockC; - const nextNode = this.cursor.getNextNode(startNode, this.isBlock, true); - assert.equal(nextNode, this.blockA); - }); - }); - }); - - 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.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - 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 = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(previousNode); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockA.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockC.getField('FIELD')); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(previousNode); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockA); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockB); - }); - test('Never valid - start at top - with loopback', function () { - const startNode = 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 = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(previousNode, this.blockC.nextConnection); - }); - test('Valid if block - start at top - with loopback', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - true, - ); - assert.equal(previousNode, this.blockC); - }); - }); - }); -}); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 7cafd00d948..0af0efbabd8 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -447,6 +447,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); @@ -530,6 +531,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 012bfe201ca..4ced6d41886 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -169,7 +169,6 @@ import './connection_test.js'; import './contextmenu_items_test.js'; import './contextmenu_test.js'; - import './cursor_test.js'; import './dialog_test.js'; import './dropdowndiv_test.js'; import './event_test.js'; @@ -221,6 +220,7 @@ import './json_test.js'; import './keyboard_movement_test.js'; import './keyboard_navigation_controller_test.js'; + import './keyboard_navigation_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; import './blocks/logic_ternary_test.js'; @@ -338,9 +338,10 @@ - + + + + diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js new file mode 100644 index 00000000000..508617abc40 --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -0,0 +1,400 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/index.js'; +import {navigationTestBlocks} from './test_helpers/navigation_test_blocks.js'; +import {p5blocks} from './test_helpers/p5_blocks.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; + +/** + * Dispatches a keydown event with the given keycode on the workspace injection + * div. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKey(workspace, keyCode, modifiers) { + const event = createKeyDownEvent(keyCode, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); +} + +/** + * Dispatches a keydown event with the given keycode multiple times. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {number} times The number of times to press the key. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKeyN(workspace, keyCode, times, modifiers) { + for (let i = 0; i < times; i++) { + pressKey(workspace, keyCode, modifiers); + } +} + +/** + * Focuses the block with the given ID on the given workspace. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block to focus. + */ +function focusBlock(workspace, blockId) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + Blockly.getFocusManager().focusNode(block); +} + +/** + * Focuses the named field on a block. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block. + * @param {string} fieldName The name of the field to focus. + */ +function focusBlockField(workspace, blockId, fieldName) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + const field = block.getField(fieldName); + if (!field) { + throw new Error(`No field found: ${fieldName} (block ${blockId})`); + } + Blockly.getFocusManager().focusNode(field); +} + +/** + * Returns the block ID of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} ID of the focused block, if any. + */ +function getFocusedBlockId() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.id; + return undefined; +} + +/** + * Returns the DOM element ID of the currently focused node's focusable element. + * + * @returns {string|undefined} ID of the focused node, if any. + */ +function getFocusNodeId() { + return Blockly.getFocusManager().getFocusedNode()?.getFocusableElement()?.id; +} + +/** + * Returns the name of the currently focused field, or undefined if the focused + * node is not a field. + * + * @returns {string|undefined} Name of the focused field, if any. + */ +function getFocusedFieldName() { + return Blockly.getFocusManager().getFocusedNode()?.name; +} + +/** + * Returns the block type of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} Type of the focused block, if any. + */ +function getFocusedBlockType() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.type; + return undefined; +} + +/** + * Focuses the workspace comment with the given ID. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the comment. + * @param {string} commentId The ID of the workspace comment to focus. + */ +function focusWorkspaceComment(workspace, commentId) { + const comment = workspace.getCommentById(commentId); + if (!comment) { + throw new Error(`No workspace comment found with ID: ${commentId}`); + } + Blockly.getFocusManager().focusNode(comment); +} + +suite('Keyboard navigation on Blocks', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + for (const block of this.workspace.getAllBlocks()) { + block.initSvg(); + block.render(); + } + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Default workspace', function () { + const blockCount = this.workspace.getAllBlocks(false).length; + assert.equal(blockCount, 16); + }); + + test('Selected block', function () { + Blockly.getFocusManager().focusTree(this.workspace); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 13); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test('Down from statement block selects next block across stacks', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + // The first down moves to the next connection on the selected block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test('Up from statement block selects previous block', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); + + test('Down from parent block selects first child block', function () { + focusBlock(this.workspace, 'p5_setup_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Up from child block selects parent block', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_setup_1'); + }); + + test('Right from block selects first field', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from block selects first inline input', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'colour_picker_1'); + }); + + test('Up from inline input selects statement block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test('Left from first inline input selects block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_modulo_1'); + }); + + test('Right from first inline input selects second inline input', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'math_number_3'); + }); + + test('Left from second inline input selects first inline input', function () { + focusBlock(this.workspace, 'math_number_3'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_number_2'); + }); + + test('Right from last inline input selects next connection', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go right twice; first one selects the field on the colour picker block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.RIGHT, 2); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('simple_circle_1').nextConnection, + ); + }); + + test('Down from inline input selects next block', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go down twice; first one selects the next connection on the colour + // picker's parent block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test("Down from inline input selects block's child block", function () { + focusBlock(this.workspace, 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'text_print_1'); + }); + + test('Right from text block selects shadow block then field', function () { + focusBlock(this.workspace, 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'text_1'); + + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'text_1_field_'); + }); +}); + +suite('Keyboard navigation on Fields', function () { + setup(function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Up from first field selects block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Left from first field selects block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Right from first field selects second field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'HEIGHT'); + }); + + test('Left from second field selects first field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from second field selects next connection', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('p5_canvas_1').nextConnection, + ); + }); + + test('Down from field selects next block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + // Go down twice; first one selects the next connection on the create + // canvas block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test("Down from field selects block's child block", function () { + focusBlockField(this.workspace, 'controls_repeat_1', 'TIMES'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); +}); + +suite('Workspace comment navigation', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + this.workspace.getTopBlocks(false).forEach((b) => b.queueRender()); + Blockly.renderManagement.triggerQueuedRenders(this.workspace); + + const comment1 = Blockly.serialization.workspaceComments.append( + {text: 'Comment one', x: 200, y: 200}, + this.workspace, + ); + const comment2 = Blockly.serialization.workspaceComments.append( + {text: 'Comment two', x: 300, y: 300}, + this.workspace, + ); + this.commentId1 = comment1.id; + this.commentId2 = comment2.id; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Navigate forward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockType(), 'p5_draw'); + }); + + test('Navigate backward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_draw_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.UP, 2); + assert.equal(getFocusedBlockType(), 'p5_canvas'); + }); + + test('Navigate forward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to workspace comment button', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusNodeId(), `${this.commentId1}_collapse_bar_button`); + }); + + test('Navigate backward from workspace comment button to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusNodeId(), this.commentId1); + }); +}); diff --git a/packages/blockly/tests/mocha/navigation_test.js b/packages/blockly/tests/mocha/navigation_test.js index 2aad16986dc..8d88c7e9151 100644 --- a/packages/blockly/tests/mocha/navigation_test.js +++ b/packages/blockly/tests/mocha/navigation_test.js @@ -471,8 +471,6 @@ suite('Navigation', function () { }); test('fromFieldToNestedBlock', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; const nextNode = this.navigator.getNextSibling(field); assert.equal(nextNode, this.blocks.fieldWithOutput); }); @@ -531,13 +529,6 @@ suite('Navigation', function () { ); assert.equal(nextNode, field); }); - test('fromBlockToFieldSkippingInput', function () { - const field = this.blocks.buttonBlock.getField('BUTTON3'); - const nextNode = this.navigator.getNextSibling( - this.blocks.buttonInput2, - ); - assert.equal(nextNode, field); - }); test('skipsChildrenOfCollapsedBlocks', function () { this.blocks.buttonBlock.setCollapsed(true); const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock); @@ -545,6 +536,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON2'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3'); const nextNode = this.navigator.getNextSibling(fieldStart); @@ -575,7 +567,6 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling( this.blocks.fieldWithOutput, ); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); }); test('fromNextToBlock', function () { @@ -622,8 +613,6 @@ suite('Navigation', function () { ); const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, outputBlock); }); @@ -692,6 +681,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON3'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2'); const nextNode = this.navigator.getPreviousSibling(fieldStart); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 608a651712b..d2d35026902 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -548,4 +548,54 @@ suite('Keyboard Shortcut Items', function () { }); }); }); + + suite('Focus Toolbox (T)', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'basic_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'default', + }, + ], + }, + ]); + }); + + test('Does not change focus when toolbox item is already focused', function () { + const item = this.workspace.getToolbox().getToolboxItems()[1]; + Blockly.getFocusManager().focusNode(item); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), item); + }); + + test('Focuses toolbox when workspace is focused', function () { + Blockly.getFocusManager().focusTree(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.workspace.getToolbox(), + ); + }); + + test('Focuses mutator flyout when mutator workspace is focused', async function () { + const block = this.workspace.newBlock('controls_if'); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + const mutatorWorkspace = icon.getWorkspace(); + Blockly.getFocusManager().focusTree(mutatorWorkspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + mutatorWorkspace.getFlyout().getWorkspace(), + ); + }); + }); }); diff --git a/packages/blockly/tests/mocha/shortcut_registry_test.js b/packages/blockly/tests/mocha/shortcut_registry_test.js index a06f01b9c00..a7a7c4d8172 100644 --- a/packages/blockly/tests/mocha/shortcut_registry_test.js +++ b/packages/blockly/tests/mocha/shortcut_registry_test.js @@ -21,6 +21,9 @@ suite('Keyboard Shortcut Registry Test', function () { }); teardown(function () { sharedTestTeardown.call(this); + this.registry.reset(); + Blockly.ShortcutItems.registerDefaultShortcuts(); + Blockly.ShortcutItems.registerKeyboardNavigationShortcuts(); }); suite('Registering', function () { @@ -528,6 +531,4 @@ suite('Keyboard Shortcut Registry Test', function () { assert.throws(shouldThrow, Error, 's is not a valid modifier key.'); }); }); - - teardown(function () {}); }); diff --git a/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js new file mode 100644 index 00000000000..a6e062b2092 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Workspace state for keyboard navigation tests. Contains: + * - p5_setup with a p5_canvas child + * - p5_draw with a nested stack: controls_if → controls_if (with + * logic_boolean input and text_print child) → controls_repeat (with + * draw_emoji and simple_circle children) → controls_repeat_ext (with a + * math_modulo expression in its TIMES input) + * + * Block IDs are stable so tests can reference them by ID. + */ +export const navigationTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup_1', + 'x': 0, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas_1', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw_1', + 'x': 0, + 'y': 332, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_1', + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_2', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'id': 'logic_boolean_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_1', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'text_1', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat', + 'id': 'controls_repeat_1', + 'fields': { + 'TIMES': 10, + }, + 'inputs': { + 'DO': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji_1', + 'fields': { + 'emoji': '❤️', + }, + 'next': { + 'block': { + 'type': 'simple_circle', + 'id': 'simple_circle_1', + 'inputs': { + 'COLOR': { + 'shadow': { + 'type': 'text', + 'id': 'colour_picker_1', + 'fields': { + 'TEXT': '#ff0000', + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext_1', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_1', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_modulo', + 'id': 'math_modulo_1', + 'inputs': { + 'DIVIDEND': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_2', + 'fields': { + 'NUM': 64, + }, + }, + }, + 'DIVISOR': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_3', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index 480fdfdc6fc..4b1af142734 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -13,7 +13,6 @@ import { import { getBasicToolbox, getCategoryJSON, - getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, @@ -26,21 +25,16 @@ import { suite('Toolbox', function () { setup(function () { sharedTestSetup.call(this); + this.toolbox = getInjectedToolbox(); defineStackBlock(); }); teardown(function () { + this.toolbox.dispose(); sharedTestTeardown.call(this); }); suite('init', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Init called -> HtmlDiv is created', function () { assert.isDefined(this.toolbox.HtmlDiv); }); @@ -87,12 +81,6 @@ suite('Toolbox', function () { }); suite('render', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Render called with valid toolboxDef -> Contents are created', function () { const positionStub = sinon.stub(this.toolbox, 'position'); this.toolbox.render({ @@ -184,13 +172,6 @@ suite('Toolbox', function () { }); suite('focus management', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Losing focus hides autoclosing flyout', function () { // Focus the toolbox and select a category to open the flyout. const target = this.toolbox.HtmlDiv.querySelector( @@ -235,13 +216,6 @@ suite('Toolbox', function () { }); suite('onClick_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Toolbox clicked -> Should close flyout', function () { const hideChaffStub = sinon.stub( Blockly.WorkspaceSvg.prototype, @@ -267,220 +241,251 @@ suite('Toolbox', function () { }); }); - suite('onKeyDown_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - - function createKeyDownMock(key) { - return { - 'key': key, - 'preventDefault': function () {}, - }; - } - - function testCorrectFunctionCalled(toolbox, key, funcName) { - const event = createKeyDownMock(key); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - const selectMethodStub = sinon.stub(toolbox, funcName); - selectMethodStub.returns(true); - toolbox.onKeyDown_(event); - sinon.assert.called(selectMethodStub); - sinon.assert.called(preventDefaultEvent); - } - - test('Down button is pushed -> Should call selectNext', function () { - testCorrectFunctionCalled(this.toolbox, 'ArrowDown', 'selectNext', true); - }); - test('Up button is pushed -> Should call selectPrevious', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowUp', - 'selectPrevious', - true, - ); - }); - test('Left button is pushed -> Should call selectParent', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowLeft', - 'selectParent', - true, - ); - }); - test('Right button is pushed -> Should call selectChild', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowRight', - 'selectChild', - true, - ); - }); - test('Enter button is pushed -> Should toggle expanded', function () { - this.toolbox.selectedItem_ = getCollapsibleItem(this.toolbox); - const toggleExpandedStub = sinon.stub( - this.toolbox.selectedItem_, - 'toggleExpanded', - ); - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.called(toggleExpandedStub); - sinon.assert.called(preventDefaultEvent); - }); - test('Enter button is pushed when no item is selected -> Should not call prevent default', function () { - this.toolbox.selectedItem_ = null; - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.notCalled(preventDefaultEvent); - }); - }); - - suite('Select Methods', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); + suite('on key down', function () { + test('Down arrow should select next item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex + 1, newIndex); }); - suite('selectChild', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); + test('Down arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have incremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex + 2, newIndex); + }); + + test('Down arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); - test('Selected item is not collapsible -> Should not handle event', function () { - this.toolbox.selectedItem_ = getNonCollapsibleItem(this.toolbox); - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so down should skip its child item + // and advance to the next regular item. + assert.equal(oldIndex + 2, newIndex); + }); + + test('Down arrow should go to first child of expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); - test('Selected item is collapsible -> Should expand', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - assert.isTrue(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so down should focus its child item. + assert.equal(oldIndex + 1, newIndex); + }); + + test('Down arrow on last item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); - test('Selected item is expanded -> Should select child', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - const selectNextStub = sinon.stub(this.toolbox, 'selectNext'); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - sinon.assert.called(selectNextStub); + test('Up arrow should select previous item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex - 1, newIndex); }); - suite('selectParent', function () { - test('No item selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectParent(); - assert.isFalse(handled); + test('Up arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[3]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have decremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); - test('Selected item is expanded -> Should collapse', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.isFalse(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so up should skip its child item + // and advance to it directly. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should go to parent from child item', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); - test('Selected item is not expanded -> Should get parent', function () { - const childItem = getChildItem(this.toolbox); - this.toolbox.selectedItem_ = childItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, childItem.getParent()); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so up from its child should go to it. + assert.equal(oldIndex - 1, newIndex); + }); + + test('Up arrow on first item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); + + test('Left arrow should collapse expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isFalse(collapsibleItem.isExpanded()); }); - suite('selectNext', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); + test('Left arrow from normal item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Next item is selectable -> Should select next item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, items[1]); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[0]); + }); + + test('Left arrow from collapsed item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Selected item is last item -> Should not handle event', function () { - const items = [...this.toolbox.contents.values()]; - const item = items.at(-1); - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[4]); + }); + + test('Left arrow from child item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', }); - test('Selected item is collapsed -> Should skip over its children', function () { - const item = getCollapsibleItem(this.toolbox); - const childItem = item.flyoutItems_[0]; - item.expanded_ = false; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[5]); + }); + + test('Right arrow should expand collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isTrue(collapsibleItem.isExpanded()); }); - suite('selectPrevious', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - }); - test('Selected item is first item -> Should not handle event', function () { - const item = [...this.toolbox.contents.values()][0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); + test('Right arrow from normal item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); - test('Previous item is selectable -> Should select previous item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[1]; - const prevItem = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, prevItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); + }); + + test('Right arrow from expanded item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); - test('Previous item is collapsed -> Should skip over children of the previous item', function () { - const childItem = getChildItem(this.toolbox); - const parentItem = childItem.getParent(); - const items = [...this.toolbox.contents.values()]; - const parentIdx = items.indexOf(parentItem); - // Gets the item after the parent. - const item = items[parentIdx + 1]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); + }); + + test('Right arrow from child item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), + ); }); }); suite('setSelectedItem', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function setupSetSelected(toolbox, oldItem, newItem) { toolbox.selectedItem_ = oldItem; const newItemStub = sinon.stub(newItem, 'setSelected'); @@ -526,13 +531,6 @@ suite('Toolbox', function () { }); suite('updateFlyout_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function testHideFlyout(toolbox, oldItem, newItem) { const updateFlyoutStub = sinon.stub(toolbox.getFlyout(), 'hide'); toolbox.updateFlyout_(oldItem, newItem); @@ -778,12 +776,6 @@ suite('Toolbox', function () { }); }); suite('Nested Categories', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Child categories visible if all ancestors expanded', function () { this.toolbox.render(getDeeplyNestedJSON()); const items = [...this.toolbox.contents.values()];