mirror of
https://github.com/google/blockly.git
synced 2026-04-26 23:20:22 +02:00
feat: Add keyboard shortcut to perform an action on the currently focused element (#9673)
* feat: Add keyboard shortcut to perform an action on the currently focused element * test: Add tests * chore: Add example of shortcut formats * fix: Don't show toast if shortcut doesn't exist * chore: Clarify use of Zelos in tests * fix: Skip help hint toast until shortcut is added
This commit is contained in:
@@ -35,6 +35,7 @@ import {EventType} from './events/type.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {FieldLabel} from './field_label.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import * as hints from './hints.js';
|
||||
import {IconType} from './icons/icon_types.js';
|
||||
import {MutatorIcon} from './icons/mutator_icon.js';
|
||||
import {WarningIcon} from './icons/warning_icon.js';
|
||||
@@ -52,6 +53,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import {IIcon} from './interfaces/i_icon.js';
|
||||
import * as internalConstants from './internal_constants.js';
|
||||
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
import {RenderedConnection} from './rendered_connection.js';
|
||||
@@ -1903,6 +1905,34 @@ export class BlockSvg
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this block via keyboard navigation.
|
||||
* If this block is in the flyout, a new copy is spawned in move mode on the
|
||||
* main workspace. If this block has a single full-block field, that field
|
||||
* will be focused. Otherwise, this is a no-op.
|
||||
*/
|
||||
performAction() {
|
||||
if (this.workspace.isFlyout) {
|
||||
KeyboardMover.mover.startMove(this);
|
||||
return;
|
||||
} else if (this.isSimpleReporter()) {
|
||||
for (const input of this.inputList) {
|
||||
for (const field of input.fieldRow) {
|
||||
if (field.isClickable() && field.isFullBlockField()) {
|
||||
field.showEditor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.workspace.getNavigator().getFirstChild(this)) {
|
||||
hints.showBlockNavigationHint(this.workspace);
|
||||
} else {
|
||||
hints.showHelpHint(this.workspace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of all of the parent blocks of the given block.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type {BlocklyOptions} from '../blockly_options.js';
|
||||
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
|
||||
import {Options} from '../options.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
@@ -287,4 +288,12 @@ export class MiniWorkspaceBubble extends Bubble {
|
||||
'monkey-patched in by blockly.ts',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this bubble via keyboard navigation by focusing
|
||||
* the mutator workspace.
|
||||
*/
|
||||
performAction() {
|
||||
getFocusManager().focusTree(this.getWorkspace());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,14 @@ export class TextInputBubble extends Bubble {
|
||||
getEditor() {
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this bubble via keyboard navigation by focusing
|
||||
* the comment editor.
|
||||
*/
|
||||
performAction() {
|
||||
getFocusManager().focusNode(this.getEditor());
|
||||
}
|
||||
}
|
||||
|
||||
Css.register(`
|
||||
|
||||
@@ -358,4 +358,13 @@ export class RenderedWorkspaceComment
|
||||
canBeFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this comment via keyboard navigation.
|
||||
* Expands the comment and focuses its editor.
|
||||
*/
|
||||
performAction() {
|
||||
this.setCollapsed(false);
|
||||
getFocusManager().focusNode(this.getEditorFocusableNode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1488,6 +1488,14 @@ export abstract class Field<T = any>
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this field via keyboard navigation.
|
||||
* Shows and focuses the field editor.
|
||||
*/
|
||||
performAction() {
|
||||
this.showEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses should reimplement this method to construct their Field
|
||||
* subclass from a JSON arg object.
|
||||
|
||||
@@ -421,6 +421,19 @@ export class FlyoutButton
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this button via keyboard navigation.
|
||||
* Invokes the click handler callback.
|
||||
*/
|
||||
performAction(): void {
|
||||
if (!this.isFlyoutLabel) {
|
||||
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
|
||||
if (callback) {
|
||||
callback(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS for buttons and labels. See css.js for use. */
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
import {Msg} from './msg.js';
|
||||
import {Toast} from './toast.js';
|
||||
import {getShortActionShortcut} from './utils/shortcut_formatting.js';
|
||||
import * as userAgent from './utils/useragent.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
|
||||
const constrainedMoveHintId = 'constrainedMoveHint';
|
||||
const helpHintId = 'helpHint';
|
||||
const blockNavigationHintId = 'blockNavigationHint';
|
||||
const workspaceNavigationHintId = 'workspaceNavigationHint';
|
||||
|
||||
/**
|
||||
* Nudge the user to use unconstrained movement.
|
||||
@@ -62,3 +66,39 @@ export function clearMoveHints(workspace: WorkspaceSvg) {
|
||||
Toast.hide(workspace, constrainedMoveHintId);
|
||||
Toast.hide(workspace, unconstrainedMoveHintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the user to open the help.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showHelpHint(workspace: WorkspaceSvg) {
|
||||
const shortcut = getShortActionShortcut('list_shortcuts');
|
||||
if (!shortcut) return;
|
||||
|
||||
const message = Msg['HELP_PROMPT'].replace('%1', shortcut);
|
||||
const id = helpHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user how to navigate inside blocks.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showBlockNavigationHint(workspace: WorkspaceSvg) {
|
||||
const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'];
|
||||
const id = blockNavigationHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user how to navigate inside the workspace.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
|
||||
const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'];
|
||||
const id = workspaceNavigationHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
import type {Block} from '../block.js';
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
|
||||
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import {hasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import type {IIcon} from '../interfaces/i_icon.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import * as tooltip from '../tooltip.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
@@ -189,6 +191,22 @@ export abstract class Icon implements IIcon, IContextMenu {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this icon via keyboard navigation.
|
||||
* Performs the same action as a click would, and focuses this icon's bubble
|
||||
* if it has one.
|
||||
*/
|
||||
performAction() {
|
||||
this.onClick();
|
||||
renderManagement.finishQueuedRenders().then(() => {
|
||||
if (hasBubble(this) && this.bubbleIsVisible()) {
|
||||
const bubble = this.getBubble();
|
||||
if (!bubble) return;
|
||||
getFocusManager().focusNode(bubble);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block that this icon is attached to.
|
||||
*
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface IFocusableNode {
|
||||
* @returns Whether this node can be focused by FocusManager.
|
||||
*/
|
||||
canBeFocused(): boolean;
|
||||
|
||||
/**
|
||||
* Optional method invoked when this node has focus and the user acts on it by
|
||||
* pressing Enter or Space. Behavior should generally be similar to the node
|
||||
* being clicked on.
|
||||
*/
|
||||
performAction?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,7 +102,7 @@ export class KeyboardMover {
|
||||
* @param event The keyboard event that triggered this move.
|
||||
* @returns True iff a move has successfully begun.
|
||||
*/
|
||||
startMove(draggable: IDraggable, event: KeyboardEvent) {
|
||||
startMove(draggable: IDraggable, event?: KeyboardEvent) {
|
||||
if (!this.canMove(draggable) || this.isMoving()) return false;
|
||||
|
||||
const DraggerClass = registry.getClassFromOptions(
|
||||
|
||||
@@ -59,6 +59,7 @@ export enum names {
|
||||
NEXT_STACK = 'next_stack',
|
||||
PREVIOUS_STACK = 'previous_stack',
|
||||
INFORMATION = 'information',
|
||||
PERFORM_ACTION = 'perform_action',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -846,6 +847,29 @@ export function registerStackNavigation() {
|
||||
ShortcutRegistry.registry.register(previousStackShortcut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers keyboard shortcut to perform an action on the focused element.
|
||||
*/
|
||||
export function registerPerformAction() {
|
||||
const performActionShortcut: KeyboardShortcut = {
|
||||
name: names.PERFORM_ACTION,
|
||||
preconditionFn: (workspace) => !workspace.isDragging(),
|
||||
callback: (_workspace, e) => {
|
||||
keyboardNavigationController.setIsActive(true);
|
||||
const focusedNode = getFocusManager().getFocusedNode();
|
||||
if (focusedNode && 'performAction' in focusedNode) {
|
||||
e.preventDefault();
|
||||
focusedNode.performAction?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE],
|
||||
allowCollision: true,
|
||||
};
|
||||
ShortcutRegistry.registry.register(performActionShortcut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all default keyboard shortcut item. This should be called once per
|
||||
* instance of KeyboardShortcutRegistry.
|
||||
@@ -874,6 +898,7 @@ export function registerKeyboardNavigationShortcuts() {
|
||||
registerArrowNavigation();
|
||||
registerDisconnectBlock();
|
||||
registerStackNavigation();
|
||||
registerPerformAction();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {Msg} from '../msg.js';
|
||||
import {ShortcutRegistry} from '../shortcut_registry.js';
|
||||
import * as userAgent from './useragent.js';
|
||||
|
||||
/**
|
||||
* Find the primary shortcut for this platform and return it as single string
|
||||
* in a short user facing format.
|
||||
*
|
||||
* @internal
|
||||
* @param action The action name, e.g. "cut".
|
||||
* @returns The formatted shortcut.
|
||||
*/
|
||||
export function getShortActionShortcut(action: string): string {
|
||||
const shortcuts = getActionShortcutsAsKeys(action, shortModifierNames);
|
||||
if (shortcuts.length) {
|
||||
const parts = shortcuts[0];
|
||||
return parts.join(userAgent.APPLE ? ' ' : ' + ');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the relevant shortcuts for the given action for the current platform.
|
||||
* Keys are returned in a long user facing format, e.g. "Command ⌘ Option ⌥ C"
|
||||
*
|
||||
* @internal
|
||||
* @param action The action name, e.g. "cut".
|
||||
* @returns The formatted shortcuts as individual keys.
|
||||
*/
|
||||
export function getLongActionShortcutsAsKeys(action: string): string[][] {
|
||||
return getActionShortcutsAsKeys(action, longModifierNames);
|
||||
}
|
||||
|
||||
const longModifierNames: Record<string, string> = {
|
||||
'Control': Msg['CONTROL_KEY'],
|
||||
'Meta': Msg['COMMAND_KEY'],
|
||||
'Alt': userAgent.APPLE ? Msg['OPTION_KEY'] : Msg['ALT_KEY'],
|
||||
};
|
||||
|
||||
const shortModifierNames: Record<string, string> = {
|
||||
'Control': Msg['CONTROL_KEY'],
|
||||
'Meta': '⌘',
|
||||
'Alt': userAgent.APPLE ? '⌥' : Msg['ALT_KEY'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the relevant shortcuts for the given action for the current platform.
|
||||
* Keys are returned in a short user facing format, e.g. "⌘ ⌥ C"
|
||||
*
|
||||
* This could be considerably simpler if we only bound shortcuts relevant to the
|
||||
* current platform or tagged them with a platform.
|
||||
*
|
||||
* @param action The action name, e.g. "cut".
|
||||
* @param modifierNames The names to use for the Meta/Control/Alt modifiers.
|
||||
* @returns The formatted shortcuts.
|
||||
*/
|
||||
function getActionShortcutsAsKeys(
|
||||
action: string,
|
||||
modifierNames: Record<string, string>,
|
||||
): string[][] {
|
||||
const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action);
|
||||
if (shortcuts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// See ShortcutRegistry.createSerializedKey for the starting format.
|
||||
const shortcutsAsParts = shortcuts.map((shortcut) => shortcut.split('+'));
|
||||
// Prefer e.g. Cmd+Shift to Shift+Cmd.
|
||||
shortcutsAsParts.forEach((s) =>
|
||||
s.sort((a, b) => {
|
||||
const aValue = modifierOrder(a);
|
||||
const bValue = modifierOrder(b);
|
||||
return aValue - bValue;
|
||||
}),
|
||||
);
|
||||
|
||||
// Needed to prefer Command to Option where we've bound Alt.
|
||||
shortcutsAsParts.sort((a, b) => {
|
||||
const aValue = a.includes('Meta') ? 1 : 0;
|
||||
const bValue = b.includes('Meta') ? 1 : 0;
|
||||
return bValue - aValue;
|
||||
});
|
||||
let currentPlatform = shortcutsAsParts.filter((shortcut) => {
|
||||
const isMacShortcut = shortcut.includes('Meta');
|
||||
return isMacShortcut === userAgent.APPLE;
|
||||
});
|
||||
currentPlatform =
|
||||
currentPlatform.length === 0 ? shortcutsAsParts : currentPlatform;
|
||||
|
||||
// Prefer simpler shortcuts. This promotes Ctrl+Y for redo.
|
||||
currentPlatform.sort((a, b) => {
|
||||
return a.length - b.length;
|
||||
});
|
||||
// If there are modifiers return only one shortcut on the assumption they are
|
||||
// intended for different platforms. Otherwise assume they are alternatives.
|
||||
const hasModifiers = currentPlatform.some((shortcut) =>
|
||||
shortcut.some(
|
||||
(key) => 'Meta' === key || 'Alt' === key || 'Control' === key,
|
||||
),
|
||||
);
|
||||
const chosen = hasModifiers ? [currentPlatform[0]] : currentPlatform;
|
||||
return chosen.map((shortcut) => {
|
||||
return shortcut
|
||||
.map((maybeNumeric) =>
|
||||
Number.isFinite(+maybeNumeric)
|
||||
? String.fromCharCode(+maybeNumeric)
|
||||
: maybeNumeric,
|
||||
)
|
||||
.map((k) => upperCaseFirst(modifierNames[k] ?? k));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the first character to uppercase.
|
||||
*
|
||||
* @param str String.
|
||||
* @returns The string in title case.
|
||||
*/
|
||||
function upperCaseFirst(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred listing order of untranslated modifiers.
|
||||
*/
|
||||
const modifierOrdering: string[] = ['Meta', 'Control', 'Alt', 'Shift'];
|
||||
|
||||
function modifierOrder(key: string): number {
|
||||
const order = modifierOrdering.indexOf(key);
|
||||
// Regular keys at the end.
|
||||
return order === -1 ? Number.MAX_VALUE : order;
|
||||
}
|
||||
@@ -84,3 +84,5 @@ export const IPHONE: boolean = isIPhone;
|
||||
export const MAC: boolean = isMac;
|
||||
|
||||
export const MOBILE: boolean = isMobile;
|
||||
|
||||
export const APPLE: boolean = MAC || IPAD || IPHONE;
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {FlyoutButton} from './flyout_button.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {Gesture} from './gesture.js';
|
||||
import {Grid} from './grid.js';
|
||||
import * as hints from './hints.js';
|
||||
import {MutatorIcon} from './icons/mutator_icon.js';
|
||||
import {isAutoHideable} from './interfaces/i_autohideable.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
@@ -2852,6 +2853,14 @@ export class WorkspaceSvg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this workspace via keyboard navigation by
|
||||
* prompting them to use the arrow keys (instead of Enter) to navigate.
|
||||
*/
|
||||
performAction() {
|
||||
hints.showWorkspaceNavigationHint(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object responsible for coordinating movement of focus between
|
||||
* items on this workspace in response to keyboard navigation commands.
|
||||
|
||||
@@ -425,5 +425,7 @@
|
||||
"WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.",
|
||||
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment"
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment",
|
||||
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks",
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate"
|
||||
}
|
||||
|
||||
@@ -432,5 +432,7 @@
|
||||
"WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'",
|
||||
"WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)",
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment."
|
||||
"WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.",
|
||||
"KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.",
|
||||
"KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused."
|
||||
}
|
||||
|
||||
@@ -1718,3 +1718,9 @@ Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_MANY = ' and %1 comments';
|
||||
/** @type {string} */
|
||||
/// ARIA live region phrase appended when there is exactly one workspace comment.
|
||||
Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_ONE = ' and one comment';
|
||||
/** @type {string} */
|
||||
/// Message shown when a user presses Enter with a navigable block focused.
|
||||
Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to navigate inside of blocks';
|
||||
/** @type {string} */
|
||||
/// Message shown when a user presses Enter with the workspace focused.
|
||||
Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate';
|
||||
|
||||
@@ -20,7 +20,8 @@ suite('Keyboard Shortcut Items', function () {
|
||||
setup(function () {
|
||||
sharedTestSetup.call(this);
|
||||
const toolbox = document.getElementById('toolbox-test');
|
||||
this.workspace = Blockly.inject('blocklyDiv', {toolbox});
|
||||
// Zelos has full-block fields, which we want to exercise in tests.
|
||||
this.workspace = Blockly.inject('blocklyDiv', {toolbox, renderer: 'zelos'});
|
||||
this.injectionDiv = this.workspace.getInjectionDiv();
|
||||
Blockly.ContextMenuRegistry.registry.reset();
|
||||
Blockly.ContextMenuItems.registerDefaultOptions();
|
||||
@@ -633,22 +634,6 @@ 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);
|
||||
@@ -1033,4 +1018,217 @@ suite('Keyboard Shortcut Items', function () {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Perform Action (Enter)', function () {
|
||||
test('Shows a toast with navigation hints on the workspace', function () {
|
||||
const toastSpy = sinon.spy(Blockly.Toast, 'show');
|
||||
|
||||
Blockly.getFocusManager().focusNode(this.workspace);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
sinon.assert.calledWith(toastSpy, this.workspace, {
|
||||
id: 'workspaceNavigationHint',
|
||||
message: Blockly.Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'],
|
||||
});
|
||||
|
||||
toastSpy.restore();
|
||||
});
|
||||
|
||||
test('Inserts blocks from the flyout in move mode', function () {
|
||||
this.workspace.getToolbox().selectItemByPosition(0);
|
||||
const block = this.workspace
|
||||
.getNavigator()
|
||||
.getFirstChild(this.workspace.getFlyout().getWorkspace());
|
||||
assert.instanceOf(block, Blockly.BlockSvg);
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
const movingBlock = Blockly.getFocusManager().getFocusedNode();
|
||||
assert.notEqual(block, movingBlock);
|
||||
assert.instanceOf(movingBlock, Blockly.BlockSvg);
|
||||
assert.isTrue(movingBlock.isDragging());
|
||||
assert.isFalse(movingBlock.workspace.isFlyout);
|
||||
|
||||
Blockly.KeyboardMover.mover.abortMove();
|
||||
});
|
||||
|
||||
test('Shows a toast with navigation hints for navigable blocks', function () {
|
||||
const toastSpy = sinon.spy(Blockly.Toast, 'show');
|
||||
|
||||
const block = this.workspace.newBlock('controls_if');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
sinon.assert.calledWith(toastSpy, this.workspace, {
|
||||
id: 'blockNavigationHint',
|
||||
message: Blockly.Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'],
|
||||
});
|
||||
toastSpy.restore();
|
||||
});
|
||||
|
||||
// Reenable this tests once the shortcut listing shortcut has been added.
|
||||
test.skip('Shows a toast with instructions to view help for non-navigable blocks', function () {
|
||||
const toastSpy = sinon.spy(Blockly.Toast, 'show');
|
||||
|
||||
const block = this.workspace.newBlock('test_align_dummy_right');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
sinon.assert.calledWith(toastSpy, this.workspace, {
|
||||
id: 'helpHint',
|
||||
message: Blockly.Msg['HELP_PROMPT'].replace('%1', ''),
|
||||
});
|
||||
toastSpy.restore();
|
||||
});
|
||||
|
||||
test('Focuses field editor for blocks with full-block fields', function () {
|
||||
const block = this.workspace.newBlock('math_number');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
Blockly.getFocusManager().focusNode(block);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
const field = block.getField('NUM');
|
||||
assert.isTrue(Blockly.WidgetDiv.isVisible());
|
||||
assert.isTrue(field.isBeingEdited_);
|
||||
});
|
||||
|
||||
test('Focuses field editor for fields', function () {
|
||||
const block = this.workspace.newBlock('logic_compare');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
const field = block.getField('OP');
|
||||
Blockly.getFocusManager().focusNode(field);
|
||||
|
||||
assert.isFalse(Blockly.DropDownDiv.isVisible());
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
assert.isTrue(Blockly.DropDownDiv.isVisible());
|
||||
});
|
||||
|
||||
test('Expands and focuses workspace comment editors', function () {
|
||||
const comment = this.workspace.newComment();
|
||||
comment.setCollapsed(true);
|
||||
Blockly.getFocusManager().focusNode(comment);
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedNode(),
|
||||
comment.getEditorFocusableNode(),
|
||||
);
|
||||
assert.isFalse(comment.view.isCollapsed());
|
||||
});
|
||||
|
||||
test('Focuses mutator workspace for mutator bubble', async function () {
|
||||
const block = this.workspace.newBlock('controls_if');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE);
|
||||
await icon.setBubbleVisible(true);
|
||||
Blockly.getFocusManager().focusNode(icon.getBubble());
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedTree(),
|
||||
icon.getWorkspace(),
|
||||
);
|
||||
});
|
||||
|
||||
test('Focuses comment editor for block comment bubble', async function () {
|
||||
const block = this.workspace.newBlock('controls_if');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
block.setCommentText('Hello');
|
||||
const icon = block.getIcon(Blockly.icons.CommentIcon.TYPE);
|
||||
await icon.setBubbleVisible(true);
|
||||
Blockly.getFocusManager().focusNode(icon.getBubble());
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedNode(),
|
||||
icon.getBubble().getEditor(),
|
||||
);
|
||||
});
|
||||
|
||||
test('Focuses bubble for icons', async function () {
|
||||
const block = this.workspace.newBlock('controls_if');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
|
||||
block.setCommentText('Hello world');
|
||||
block.setWarningText('Danger!');
|
||||
|
||||
const iconTypes = [
|
||||
Blockly.icons.CommentIcon.TYPE,
|
||||
Blockly.icons.WarningIcon.TYPE,
|
||||
Blockly.icons.MutatorIcon.TYPE,
|
||||
];
|
||||
|
||||
for (const iconType of iconTypes) {
|
||||
const icon = block.getIcon(iconType);
|
||||
Blockly.getFocusManager().focusNode(icon);
|
||||
|
||||
const bubbleShown = new Promise((resolve) => {
|
||||
this.workspace.addChangeListener((event) => {
|
||||
if (event.type === Blockly.Events.BUBBLE_OPEN) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
this.clock.tick(100);
|
||||
|
||||
await bubbleShown;
|
||||
assert.strictEqual(
|
||||
Blockly.getFocusManager().getFocusedNode(),
|
||||
icon.getBubble(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Triggers flyout button actions', function () {
|
||||
const toolbox = this.workspace.getToolbox();
|
||||
toolbox.selectItemByPosition(3);
|
||||
const button = this.workspace.getFlyout().getContents()[0].getElement();
|
||||
assert.instanceOf(button, Blockly.FlyoutButton);
|
||||
Blockly.getFocusManager().focusNode(button);
|
||||
|
||||
const oldCallback = this.workspace.getButtonCallback('CREATE_VARIABLE');
|
||||
let called = false;
|
||||
this.workspace.registerButtonCallback('CREATE_VARIABLE', () => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
|
||||
this.workspace.getInjectionDiv().dispatchEvent(event);
|
||||
|
||||
assert.isTrue(called);
|
||||
this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user