diff --git a/src/actions/arrow_navigation.ts b/src/actions/arrow_navigation.ts index e0eef938..7a00d07e 100644 --- a/src/actions/arrow_navigation.ts +++ b/src/actions/arrow_navigation.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ShortcutRegistry, utils as BlocklyUtils, Field} from 'blockly/core'; +import { + ShortcutRegistry, + utils as BlocklyUtils, + Field, + keyboardNavigationController, +} from 'blockly/core'; import type {Toolbox, WorkspaceSvg} from 'blockly/core'; @@ -122,6 +127,7 @@ export class ArrowNavigation { preconditionFn: (workspace) => this.navigation.canCurrentlyNavigate(workspace), callback: (workspace, e, shortcut) => { + keyboardNavigationController.setIsActive(true); return workspace.RTL ? navigateOut(workspace, e, shortcut) : navigateIn(workspace, e, shortcut); @@ -135,6 +141,7 @@ export class ArrowNavigation { preconditionFn: (workspace) => this.navigation.canCurrentlyNavigate(workspace), callback: (workspace, e, shortcut) => { + keyboardNavigationController.setIsActive(true); return workspace.RTL ? navigateIn(workspace, e, shortcut) : navigateOut(workspace, e, shortcut); @@ -148,6 +155,7 @@ export class ArrowNavigation { preconditionFn: (workspace) => this.navigation.canCurrentlyNavigate(workspace), callback: (workspace, e, shortcut) => { + keyboardNavigationController.setIsActive(true); const toolbox = workspace.getToolbox() as Toolbox; const flyout = workspace.getFlyout(); let isHandled = false; @@ -205,6 +213,7 @@ export class ArrowNavigation { preconditionFn: (workspace) => this.navigation.canCurrentlyNavigate(workspace), callback: (workspace, e, shortcut) => { + keyboardNavigationController.setIsActive(true); const flyout = workspace.getFlyout(); const toolbox = workspace.getToolbox() as Toolbox; let isHandled = false; diff --git a/src/actions/disconnect.ts b/src/actions/disconnect.ts index a38e4c25..3604dec2 100644 --- a/src/actions/disconnect.ts +++ b/src/actions/disconnect.ts @@ -11,6 +11,7 @@ import { utils as BlocklyUtils, Connection, ConnectionType, + keyboardNavigationController, } from 'blockly'; import * as Constants from '../constants'; import type {WorkspaceSvg} from 'blockly'; @@ -56,6 +57,7 @@ export class DisconnectAction { preconditionFn: (workspace) => this.navigation.canCurrentlyEdit(workspace), callback: (workspace) => { + keyboardNavigationController.setIsActive(true); switch (this.navigation.getState(workspace)) { case Constants.STATE.WORKSPACE: this.disconnectBlocks(workspace); diff --git a/src/actions/edit.ts b/src/actions/edit.ts index 60346080..b864f286 100644 --- a/src/actions/edit.ts +++ b/src/actions/edit.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ContextMenuRegistry, LineCursor, Msg} from 'blockly'; +import { + ContextMenuRegistry, + LineCursor, + Msg, + keyboardNavigationController, +} from 'blockly'; import {Navigation} from 'src/navigation'; import {getShortActionShortcut} from '../shortcut_formatting'; import * as Constants from '../constants'; @@ -67,6 +72,7 @@ export class EditAction { return cursor.atEndOfLine() ? 'hidden' : 'enabled'; }, callback: (scope: ContextMenuRegistry.Scope) => { + keyboardNavigationController.setIsActive(true); const workspace = scope.block?.workspace; if (!workspace) return false; workspace.getCursor()?.in(); diff --git a/src/actions/move.ts b/src/actions/move.ts index 415fe40c..045a1098 100644 --- a/src/actions/move.ts +++ b/src/actions/move.ts @@ -11,6 +11,7 @@ import { ShortcutRegistry, utils, WorkspaceSvg, + keyboardNavigationController, } from 'blockly'; import {Direction} from '../drag_direction'; import {Mover} from './mover'; @@ -40,6 +41,7 @@ export class MoveActions { return !!startBlock && this.mover.canMove(workspace, startBlock); }, callback: (workspace) => { + keyboardNavigationController.setIsActive(true); const startBlock = this.getCurrentBlock(workspace); return ( !!startBlock && this.mover.startMove(workspace, startBlock, null) diff --git a/src/actions/ws_movement.ts b/src/actions/ws_movement.ts index 987736d9..3d4dfe50 100644 --- a/src/actions/ws_movement.ts +++ b/src/actions/ws_movement.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {ShortcutRegistry, utils as BlocklyUtils} from 'blockly'; +import { + ShortcutRegistry, + utils as BlocklyUtils, + keyboardNavigationController, +} from 'blockly'; import * as Constants from '../constants'; import type {WorkspaceSvg} from 'blockly'; import {Navigation} from 'src/navigation'; @@ -66,7 +70,10 @@ export class WorkspaceMovement { name: Constants.SHORTCUT_NAMES.CREATE_WS_CURSOR, preconditionFn: (workspace) => this.navigation.canCurrentlyEdit(workspace), - callback: (workspace) => this.createWSCursor(workspace), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + return this.createWSCursor(workspace); + }, keyCodes: [KeyCodes.W], }, ]; diff --git a/src/index.ts b/src/index.ts index 99c9f17e..efe96d58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ import * as Blockly from 'blockly/core'; import {NavigationController} from './navigation_controller'; import {enableBlocksOnDrag} from './disabled_blocks'; -import {InputModeTracker} from './input_mode_tracker'; /** Plugin for keyboard navigation. */ export class KeyboardNavigation { @@ -20,11 +19,6 @@ export class KeyboardNavigation { /** Cursor for the main workspace. */ private cursor: Blockly.LineCursor; - /** - * Input mode tracking. - */ - private inputModeTracker: InputModeTracker; - /** * Focus ring in the workspace. */ @@ -54,7 +48,6 @@ export class KeyboardNavigation { this.navigationController.init(); this.navigationController.addWorkspace(workspace); this.navigationController.enable(workspace); - this.inputModeTracker = new InputModeTracker(workspace); this.cursor = new Blockly.LineCursor(workspace); @@ -124,7 +117,6 @@ export class KeyboardNavigation { // Remove the event listener that enables blocks on drag this.workspace.removeChangeListener(enableBlocksOnDrag); this.navigationController.dispose(); - this.inputModeTracker.dispose(); } /** diff --git a/src/input_mode_tracker.ts b/src/input_mode_tracker.ts deleted file mode 100644 index c2a4603b..00000000 --- a/src/input_mode_tracker.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {WorkspaceSvg} from 'blockly'; - -/** - * Types of user input. - */ -const enum InputMode { - Keyboard, - Pointer, -} - -/** - * Tracks the most recent input mode and sets a class indicating we're in - * keyboard nav mode. - */ -export class InputModeTracker { - private lastEventMode: InputMode | null = null; - - private pointerEventHandler = () => { - this.lastEventMode = InputMode.Pointer; - }; - private keyboardEventHandler = () => { - this.lastEventMode = InputMode.Keyboard; - }; - private focusChangeHandler = () => { - const isKeyboard = this.lastEventMode === InputMode.Keyboard; - const classList = this.workspace.getInjectionDiv().classList; - const className = 'blocklyKeyboardNavigation'; - if (isKeyboard) { - classList.add(className); - } else { - classList.remove(className); - } - }; - - constructor(private workspace: WorkspaceSvg) { - document.addEventListener('pointerdown', this.pointerEventHandler, true); - document.addEventListener('keydown', this.keyboardEventHandler, true); - document.addEventListener('focusout', this.focusChangeHandler, true); - document.addEventListener('focusin', this.focusChangeHandler, true); - } - - dispose() { - document.removeEventListener('pointerdown', this.pointerEventHandler, true); - document.removeEventListener('keydown', this.keyboardEventHandler, true); - document.removeEventListener('focusout', this.focusChangeHandler, true); - document.removeEventListener('focusin', this.focusChangeHandler, true); - } -} diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index f3831077..b27f4ccf 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -18,6 +18,7 @@ import { Toolbox, utils as BlocklyUtils, WorkspaceSvg, + keyboardNavigationController, } from 'blockly/core'; import * as Constants from './constants'; @@ -198,6 +199,7 @@ export class NavigationController { preconditionFn: (workspace) => !workspace.isDragging() && this.navigation.canCurrentlyEdit(workspace), callback: (workspace) => { + keyboardNavigationController.setIsActive(true); switch (this.navigation.getState(workspace)) { case Constants.STATE.WORKSPACE: Blockly.getFocusManager().focusTree( diff --git a/test/webdriverio/test/basic_test.ts b/test/webdriverio/test/basic_test.ts index c8701140..2902e28d 100644 --- a/test/webdriverio/test/basic_test.ts +++ b/test/webdriverio/test/basic_test.ts @@ -43,6 +43,7 @@ suite('Keyboard navigation on Blocks', function () { test('Selected block', async function () { await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); await keyDown(this.browser, 14); diff --git a/test/webdriverio/test/keyboard_mode_test.ts b/test/webdriverio/test/keyboard_mode_test.ts new file mode 100644 index 00000000..98ed5fbe --- /dev/null +++ b/test/webdriverio/test/keyboard_mode_test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as chai from 'chai'; +import * as Blockly from 'blockly'; +import { + focusOnBlock, + testSetup, + testFileLocations, + PAUSE_TIME, + getBlockElementById, + tabNavigateToWorkspace, +} from './test_setup.js'; +import {Key} from 'webdriverio'; + +const isKeyboardNavigating = function (browser: WebdriverIO.Browser) { + return browser.execute(() => { + return document.body.classList.contains('blocklyKeyboardNavigation'); + }); +}; + +suite( + 'Keyboard navigation mode set on mouse or keyboard interaction', + function () { + // Setting timeout to unlimited as these tests take a longer time to run than most mocha tests + this.timeout(0); + + setup(async function () { + // Reload the page between tests + this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); + + await this.browser.pause(PAUSE_TIME); + + // Reset the keyboard navigation state between tests. + await this.browser.execute(() => { + Blockly.keyboardNavigationController.setIsActive(false); + }); + + // Start with the workspace focused. + await tabNavigateToWorkspace(this.browser); + }); + + test('T to open toolbox enables keyboard mode', async function () { + await this.browser.pause(PAUSE_TIME); + await this.browser.keys('t'); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('M for move mode enables keyboard mode', async function () { + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys('m'); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('W for workspace cursor enables keyboard mode', async function () { + await this.browser.pause(PAUSE_TIME); + await this.browser.keys('w'); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('X to disconnect enables keyboard mode', async function () { + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys('x'); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('Copy does not change keyboard mode state', async function () { + // Make sure we're on a copyable block so that copy occurs + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Ctrl); + await this.browser.keys('c'); + await this.browser.keys(Key.Ctrl); // release ctrl key + await this.browser.pause(PAUSE_TIME); + + chai.assert.isFalse(await isKeyboardNavigating(this.browser)); + + this.browser.execute(() => { + Blockly.keyboardNavigationController.setIsActive(true); + }); + + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Ctrl); + await this.browser.keys('c'); + await this.browser.keys(Key.Ctrl); // release ctrl key + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('Delete does not change keyboard mode state', async function () { + // Make sure we're on a deletable block so that delete occurs + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Backspace); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isFalse(await isKeyboardNavigating(this.browser)); + + this.browser.execute(() => { + Blockly.keyboardNavigationController.setIsActive(true); + }); + + // Focus a different deletable block + await focusOnBlock(this.browser, 'controls_if_1'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Backspace); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue(await isKeyboardNavigating(this.browser)); + }); + + test('Right clicking a block disables keyboard mode', async function () { + await this.browser.execute(() => { + Blockly.keyboardNavigationController.setIsActive(true); + }); + + await this.browser.pause(PAUSE_TIME); + // Right click a block + const element = await getBlockElementById(this.browser, 'controls_if_1'); + await element.click({button: 'right'}); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isFalse(await isKeyboardNavigating(this.browser)); + }); + + test('Dragging a block with mouse disables keyboard mode', async function () { + await this.browser.execute(() => { + Blockly.keyboardNavigationController.setIsActive(true); + }); + + await this.browser.pause(PAUSE_TIME); + // Drag a block + const element = await getBlockElementById(this.browser, 'controls_if_1'); + await element.dragAndDrop({x: 10, y: 10}); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isFalse(await isKeyboardNavigating(this.browser)); + }); + }, +);