diff --git a/packages/blockly/core/bubbles/mini_workspace_bubble.ts b/packages/blockly/core/bubbles/mini_workspace_bubble.ts index 40585179623..01e54fe4a39 100644 --- a/packages/blockly/core/bubbles/mini_workspace_bubble.ts +++ b/packages/blockly/core/bubbles/mini_workspace_bubble.ts @@ -135,7 +135,7 @@ export class MiniWorkspaceBubble extends Bubble { options.moveOptions.drag ) { throw new Error( - 'The miniworkspace bubble does not scrolling/moving the workspace', + 'The miniworkspace bubble does not allow scrolling/moving the workspace', ); } if (options.horizontalLayout) { diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 3521de412da..8d59c361375 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -94,6 +94,10 @@ export enum names { JUMP_LAST_BLOCK = 'jump_to_last_block', JUMP_PREVIOUS_PAGE = 'jump_to_previous_page', JUMP_NEXT_PAGE = 'jump_to_next_page', + SCROLL_LEFT = 'scroll_left', + SCROLL_RIGHT = 'scroll_right', + SCROLL_UP = 'scroll_up', + SCROLL_DOWN = 'scroll_down', } /** @@ -478,6 +482,27 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Ctrl/Cmd + arrow keys. Shared by unconstrained keyboard move (`MOVE_*`) and + * workspace scroll (`SCROLL_*`). Scroll shortcuts are safely registered after move + * shortcuts because scroll shortcuts do not work when a keyboard move is in progress. + */ +const CTRL_CMD_LEFT = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.LEFT, + [KeyCodes.CTRL_CMD], +); +const CTRL_CMD_RIGHT = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.RIGHT, + [KeyCodes.CTRL_CMD], +); +const CTRL_CMD_UP = ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ + KeyCodes.CTRL_CMD, +]); +const CTRL_CMD_DOWN = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.DOWN, + [KeyCodes.CTRL_CMD], +); + /** * Registers keyboard shortcuts for keyboard-driven movement of workspace * elements. @@ -554,12 +579,7 @@ export function registerMovementShortcuts() { e.preventDefault(); return KeyboardMover.mover.move(Direction.LEFT, e as KeyboardEvent); }, - keyCodes: [ - KeyCodes.LEFT, - ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [ - KeyCodes.CTRL_CMD, - ]), - ], + keyCodes: [KeyCodes.LEFT, CTRL_CMD_LEFT], allowCollision: true, displayText: () => Msg['SHORTCUTS_MOVE_LEFT'], }, @@ -570,12 +590,7 @@ export function registerMovementShortcuts() { e.preventDefault(); return KeyboardMover.mover.move(Direction.RIGHT, e as KeyboardEvent); }, - keyCodes: [ - KeyCodes.RIGHT, - ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [ - KeyCodes.CTRL_CMD, - ]), - ], + keyCodes: [KeyCodes.RIGHT, CTRL_CMD_RIGHT], allowCollision: true, displayText: () => Msg['SHORTCUTS_MOVE_RIGHT'], }, @@ -586,12 +601,7 @@ export function registerMovementShortcuts() { e.preventDefault(); return KeyboardMover.mover.move(Direction.UP, e as KeyboardEvent); }, - keyCodes: [ - KeyCodes.UP, - ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ - KeyCodes.CTRL_CMD, - ]), - ], + keyCodes: [KeyCodes.UP, CTRL_CMD_UP], allowCollision: true, displayText: () => Msg['SHORTCUTS_MOVE_UP'], }, @@ -602,12 +612,7 @@ export function registerMovementShortcuts() { e.preventDefault(); return KeyboardMover.mover.move(Direction.DOWN, e as KeyboardEvent); }, - keyCodes: [ - KeyCodes.DOWN, - ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [ - KeyCodes.CTRL_CMD, - ]), - ], + keyCodes: [KeyCodes.DOWN, CTRL_CMD_DOWN], allowCollision: true, displayText: () => Msg['SHORTCUTS_MOVE_DOWN'], }, @@ -1808,6 +1813,153 @@ export function registerJumpNextPage() { ShortcutRegistry.registry.register(jumpNextPageShortcut); } +/** + * Pixels to scroll with each workspace shortcut. + */ +const SCROLL_PIXELS = 50; + +/** Cardinal directions in which the workspace can be scrolled. */ +enum ScrollDirection { + LEFT, + RIGHT, + UP, + DOWN, +} + +/** + * Maps scroll directions to the message keys used to announce them to the ARIA live region. + */ +const SCROLL_ANNOUNCEMENT_KEYS: Record = { + [ScrollDirection.LEFT]: 'ANNOUNCE_SCROLLED_LEFT', + [ScrollDirection.RIGHT]: 'ANNOUNCE_SCROLLED_RIGHT', + [ScrollDirection.UP]: 'ANNOUNCE_SCROLLED_UP', + [ScrollDirection.DOWN]: 'ANNOUNCE_SCROLLED_DOWN', +}; + +/** + * Returns true if the workspace can be scrolled by a shortcut. + * Workspaces inside mutator bubbles are never scrollable. + */ +const canScrollWorkspace = (workspace: WorkspaceSvg) => { + return ( + !workspace.isDragging() && + workspace.isMovable() && + !workspace.targetWorkspace?.isMutator && + !KeyboardMover.mover.isMoving() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible() + ); +}; + +/** + * Beeps and announces that the workspace cannot scroll further. + */ +const scrollFailure = (workspace: WorkspaceSvg) => { + workspace.getAudioManager().playErrorBeep(); + aria.announceDynamicAriaState(Msg['ANNOUNCE_CANT_SCROLL_FURTHER']); +}; + +/** + * Scrolls the workspace in the given direction. + * + * @param workspace The workspace to scroll. + * @param e The key event that triggered the shortcut. + * @param direction The direction to scroll. + * @returns True so the key is consumed. + */ +const scrollWorkspace = ( + workspace: WorkspaceSvg, + e: Event, + direction: ScrollDirection, +): boolean => { + e.preventDefault(); + + const horizontal = + direction === ScrollDirection.LEFT || direction === ScrollDirection.RIGHT; + const canScroll = horizontal + ? workspace.isMovableHorizontally() + : workspace.isMovableVertically(); + if (!canScroll) { + scrollFailure(workspace); + return true; + } + + let deltaX = 0; + let deltaY = 0; + switch (direction) { + case ScrollDirection.LEFT: + deltaX = SCROLL_PIXELS; + break; + case ScrollDirection.RIGHT: + deltaX = -SCROLL_PIXELS; + break; + case ScrollDirection.UP: + deltaY = SCROLL_PIXELS; + break; + case ScrollDirection.DOWN: + deltaY = -SCROLL_PIXELS; + break; + } + const oldX = workspace.scrollX; + const oldY = workspace.scrollY; + workspace.scroll(oldX + deltaX, oldY + deltaY); + if (workspace.scrollX === oldX && workspace.scrollY === oldY) { + scrollFailure(workspace); + return true; + } + aria.announceDynamicAriaState(Msg[SCROLL_ANNOUNCEMENT_KEYS[direction]]); + return true; +}; + +/** + * Registers keyboard shortcuts to scroll the focused workspace or flyout with + * Ctrl/Cmd + arrow keys. + */ +export function registerWorkspaceScrollShortcuts() { + const shortcuts: KeyboardShortcut[] = [ + { + name: names.SCROLL_LEFT, + preconditionFn: canScrollWorkspace, + callback: (workspace, e) => + scrollWorkspace(workspace, e, ScrollDirection.LEFT), + keyCodes: [CTRL_CMD_LEFT], + allowCollision: true, + displayText: () => Msg['SHORTCUTS_SCROLL_LEFT'], + }, + { + name: names.SCROLL_RIGHT, + preconditionFn: canScrollWorkspace, + callback: (workspace, e) => + scrollWorkspace(workspace, e, ScrollDirection.RIGHT), + keyCodes: [CTRL_CMD_RIGHT], + allowCollision: true, + displayText: () => Msg['SHORTCUTS_SCROLL_RIGHT'], + }, + { + name: names.SCROLL_UP, + preconditionFn: canScrollWorkspace, + callback: (workspace, e) => + scrollWorkspace(workspace, e, ScrollDirection.UP), + keyCodes: [CTRL_CMD_UP], + allowCollision: true, + displayText: () => Msg['SHORTCUTS_SCROLL_UP'], + }, + { + name: names.SCROLL_DOWN, + preconditionFn: canScrollWorkspace, + callback: (workspace, e) => + scrollWorkspace(workspace, e, ScrollDirection.DOWN), + keyCodes: [CTRL_CMD_DOWN], + allowCollision: true, + displayText: () => Msg['SHORTCUTS_SCROLL_DOWN'], + }, + ]; + + for (const shortcut of shortcuts) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -1853,8 +2005,9 @@ export function registerScreenReaderShortcuts() { } /** - * Registers keyboard shortcuts used to jump between blocks and stacks in the workspace, - * and between items in the toolbox and flyout. + * Registers keyboard shortcuts used to jump between blocks and stacks in the + * workspace, between items in the toolbox and flyout, and to scroll the + * workspace or flyout. * Note these are not registered by default, so call this function to enable them if desired. */ export function registerNavigationShortcuts() { @@ -1866,6 +2019,7 @@ export function registerNavigationShortcuts() { registerJumpLastBlock(); registerJumpPreviousPage(); registerJumpNextPage(); + registerWorkspaceScrollShortcuts(); } registerDefaultShortcuts(); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 10432ea20d0..7dd6e57236c 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -495,6 +495,15 @@ "SHORTCUTS_JUMP_LAST_BLOCK": "Jump to last block", "SHORTCUTS_JUMP_PREVIOUS_PAGE": "Jump to previous page", "SHORTCUTS_JUMP_NEXT_PAGE": "Jump to next page", + "SHORTCUTS_SCROLL_LEFT": "Scroll left", + "SHORTCUTS_SCROLL_RIGHT": "Scroll right", + "SHORTCUTS_SCROLL_UP": "Scroll up", + "SHORTCUTS_SCROLL_DOWN": "Scroll down", + "ANNOUNCE_SCROLLED_LEFT": "Scrolled left.", + "ANNOUNCE_SCROLLED_RIGHT": "Scrolled right.", + "ANNOUNCE_SCROLLED_UP": "Scrolled up.", + "ANNOUNCE_SCROLLED_DOWN": "Scrolled down.", + "ANNOUNCE_CANT_SCROLL_FURTHER": "Can't scroll further.", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position.", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 4fee22b3d60..c86ac7f2255 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -500,6 +500,15 @@ "SHORTCUTS_JUMP_LAST_BLOCK": "shortcut display text for a shortcut that jumps focus to the last block in the workspace.", "SHORTCUTS_JUMP_PREVIOUS_PAGE": "shortcut display text for a shortcut that jumps focus back one page of items in the toolbox or flyout.", "SHORTCUTS_JUMP_NEXT_PAGE": "shortcut display text for a shortcut that jumps focus forward one page of items in the toolbox or flyout.", + "SHORTCUTS_SCROLL_LEFT": "shortcut display text for a shortcut that scrolls the workspace or flyout to the left.", + "SHORTCUTS_SCROLL_RIGHT": "shortcut display text for a shortcut that scrolls the workspace or flyout to the right.", + "SHORTCUTS_SCROLL_UP": "shortcut display text for a shortcut that scrolls the workspace or flyout up.", + "SHORTCUTS_SCROLL_DOWN": "shortcut display text for a shortcut that scrolls the workspace or flyout down.", + "ANNOUNCE_SCROLLED_LEFT": "ARIA live region message announcing that the workspace or flyout scrolled left.", + "ANNOUNCE_SCROLLED_RIGHT": "ARIA live region message announcing that the workspace or flyout scrolled right.", + "ANNOUNCE_SCROLLED_UP": "ARIA live region message announcing that the workspace or flyout scrolled up.", + "ANNOUNCE_SCROLLED_DOWN": "ARIA live region message announcing that the workspace or flyout scrolled down.", + "ANNOUNCE_CANT_SCROLL_FURTHER": "ARIA live region message announcing that the workspace or flyout cannot scroll further in the requested direction.", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 60d6461c37d..a20a720df38 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1923,6 +1923,33 @@ Blockly.Msg.SHORTCUTS_JUMP_PREVIOUS_PAGE = 'Jump to previous page'; /// shortcut display text for a shortcut that jumps focus forward one page of items in the toolbox or flyout. Blockly.Msg.SHORTCUTS_JUMP_NEXT_PAGE = 'Jump to next page'; /** @type {string} */ +/// shortcut display text for a shortcut that scrolls the workspace or flyout to the left. +Blockly.Msg.SHORTCUTS_SCROLL_LEFT = 'Scroll left'; +/** @type {string} */ +/// shortcut display text for a shortcut that scrolls the workspace or flyout to the right. +Blockly.Msg.SHORTCUTS_SCROLL_RIGHT = 'Scroll right'; +/** @type {string} */ +/// shortcut display text for a shortcut that scrolls the workspace or flyout up. +Blockly.Msg.SHORTCUTS_SCROLL_UP = 'Scroll up'; +/** @type {string} */ +/// shortcut display text for a shortcut that scrolls the workspace or flyout down. +Blockly.Msg.SHORTCUTS_SCROLL_DOWN = 'Scroll down'; +/** @type {string} */ +/// ARIA live region message announcing that the workspace or flyout scrolled left. +Blockly.Msg.ANNOUNCE_SCROLLED_LEFT = 'Scrolled left.'; +/** @type {string} */ +/// ARIA live region message announcing that the workspace or flyout scrolled right. +Blockly.Msg.ANNOUNCE_SCROLLED_RIGHT = 'Scrolled right.'; +/** @type {string} */ +/// ARIA live region message announcing that the workspace or flyout scrolled up. +Blockly.Msg.ANNOUNCE_SCROLLED_UP = 'Scrolled up.'; +/** @type {string} */ +/// ARIA live region message announcing that the workspace or flyout scrolled down. +Blockly.Msg.ANNOUNCE_SCROLLED_DOWN = 'Scrolled down.'; +/** @type {string} */ +/// ARIA live region message announcing that the workspace or flyout cannot scroll further in the requested direction. +Blockly.Msg.ANNOUNCE_CANT_SCROLL_FURTHER = 'Can\'t scroll further.'; +/** @type {string} */ /// Message shown to inform users how to move blocks to arbitrary locations /// with the keyboard. Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position.'; diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js index e9abd57d6cb..49179f5211c 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -1097,6 +1097,10 @@ suite('Toolbox and flyout jump shortcuts (Ctrl/Cmd + Home / End)', function () { 'jump_to_last_block', 'jump_to_previous_page', 'jump_to_next_page', + 'scroll_left', + 'scroll_right', + 'scroll_up', + 'scroll_down', ]) { Blockly.ShortcutRegistry.registry.unregister(shortcut); } @@ -1249,6 +1253,10 @@ suite('Toolbox and flyout paging shortcuts (Page Up / Page Down)', function () { 'jump_to_last_block', 'jump_to_previous_page', 'jump_to_next_page', + 'scroll_left', + 'scroll_right', + 'scroll_up', + 'scroll_down', ]) { Blockly.ShortcutRegistry.registry.unregister(shortcut); } diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 3b46e096dda..1c5a444b76e 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -2220,6 +2220,10 @@ suite('Keyboard Shortcut Items', function () { 'jump_to_last_block', 'jump_to_previous_page', 'jump_to_next_page', + 'scroll_left', + 'scroll_right', + 'scroll_up', + 'scroll_down', ]) { Blockly.ShortcutRegistry.registry.unregister(shortcut); } @@ -2435,3 +2439,113 @@ suite('Keyboard Shortcut Items', function () { }); }); }); + +suite('Workspace scroll shortcuts', function () { + suiteSetup(function () { + Blockly.ShortcutItems.registerNavigationShortcuts(); + }); + + suiteTeardown(function () { + for (const shortcut of [ + 'jump_to_top_of_stack', + 'jump_to_bottom_of_stack', + 'jump_to_block_start', + 'jump_to_block_end', + 'jump_to_first_block', + 'jump_to_last_block', + 'jump_to_previous_page', + 'jump_to_next_page', + 'scroll_left', + 'scroll_right', + 'scroll_up', + 'scroll_down', + ]) { + Blockly.ShortcutRegistry.registry.unregister(shortcut); + } + }); + + /** + * Dispatches Ctrl/Cmd + the given arrow key and flushes timers. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace whose injection + * div receives the event. + * @param {number} keyCode Arrow key code. + */ + function pressScroll(workspace, keyCode) { + workspace + .getInjectionDiv() + .dispatchEvent( + createKeyDownEvent(keyCode, [Blockly.utils.KeyCodes.CTRL_CMD]), + ); + this.clock.runAll(); + } + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', { + ...DEFAULT_INJECT_OPTIONS, + move: {scrollbars: true}, + }); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + this.workspace.scrollCenter(); + Blockly.getFocusManager().focusNode(this.workspace); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Cmd+Right scrolls the workspace to the right', function () { + const startX = this.workspace.scrollX; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.isBelow(this.workspace.scrollX, startX); + }); + + test('Cmd+Left scrolls the workspace to the left', function () { + const startX = this.workspace.scrollX; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.isAbove(this.workspace.scrollX, startX); + }); + + test('Cmd+Down scrolls the workspace down', function () { + const startY = this.workspace.scrollY; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.isBelow(this.workspace.scrollY, startY); + }); + + test('Cmd+Up scrolls the workspace up', function () { + const startY = this.workspace.scrollY; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.UP); + assert.isAbove(this.workspace.scrollY, startY); + }); + + test('Announces when the workspace is scrolled', function () { + const startX = this.workspace.scrollX; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(this.liveRegion.textContent, 'Scrolled right.'); + assert.isBelow(this.workspace.scrollX, startX); + }); + + test('Does not scroll past the edge', function () { + const metrics = this.workspace.getMetrics(); + this.workspace.scroll(-metrics.scrollLeft, this.workspace.scrollY); + const startX = this.workspace.scrollX; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(this.workspace.scrollX, startX); + assert.include(this.liveRegion.textContent, "Can't scroll further."); + }); + + test('Does not scroll while a keyboard move is in progress', function () { + const block = this.workspace.newBlock('logic_boolean'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + this.workspace + .getInjectionDiv() + .dispatchEvent(createKeyDownEvent(Blockly.utils.KeyCodes.M)); + assert.isTrue(Blockly.KeyboardMover.mover.isMoving()); + const startX = this.workspace.scrollX; + pressScroll.call(this, this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(this.workspace.scrollX, startX); + Blockly.KeyboardMover.mover.abortMove(); + }); +}); diff --git a/packages/docs/docs/guides/configure/keyboard-nav.mdx b/packages/docs/docs/guides/configure/keyboard-nav.mdx index 6b859c556ac..aa0d17ab57c 100644 --- a/packages/docs/docs/guides/configure/keyboard-nav.mdx +++ b/packages/docs/docs/guides/configure/keyboard-nav.mdx @@ -94,6 +94,7 @@ We also provide optional navigation shortcuts to make navigating the workspace e | Page Down | Jump to bottom of stack | | Ctrl/Cmd + Home | Jump to first block | | Ctrl/Cmd + End | Jump to last block | +| Ctrl/Cmd + Arrow | Scroll the workspace or flyout | When focus is in the toolbox or the flyout, the same keys move between the items there instead: