diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index 612845aaeba4..5657aefc55f8 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -397,7 +397,6 @@ declare namespace pxt { lightToc?: boolean; // if true: do NOT use inverted style in docs toc // FIXME (riknoll): Can't use Blockly types here blocklyOptions?: any; // Blockly options, see Configuration: https://developers.google.com/blockly/guides/get-started/web - blocklyKeyboardControlsByDefault?: boolean; // if true, keyboard controls will be enabled by default in the blockly editor hideFlyoutHeadings?: boolean; // Hide the flyout headings at the top of the flyout when on a mobile device. monacoColors?: pxt.Map; // Monaco theme colors, see https://code.visualstudio.com/docs/getstarted/theme-color-reference simAnimationEnter?: string; // Simulator enter animation diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index 0a8d34c43f03..4e15514f85b2 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -813,7 +813,6 @@ declare namespace pxt.editor { editorOffset?: string; print?: boolean; greenScreen?: boolean; - accessibleBlocks?: boolean; home?: boolean; hasError?: boolean; cancelledDownload?: boolean; @@ -1049,8 +1048,6 @@ declare namespace pxt.editor { toggleHighContrast(): void; setHighContrast(on: boolean): void; toggleGreenScreen(): void; - toggleAccessibleBlocks(eventSource: string): void; - isAccessibleBlocks(): boolean; launchFullEditor(): void; resetWorkspace(): void; diff --git a/package.json b/package.json index 42383b49a780..4755d537b699 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ }, "dependencies": { "@blockly/field-grid-dropdown": "6.0.4", - "@blockly/keyboard-navigation": "3.0.3", "@blockly/plugin-workspace-search": "10.1.2", "@crowdin/crowdin-api-client": "^1.33.0", "@fortawesome/fontawesome-free": "^5.15.4", @@ -72,7 +71,7 @@ "@zip.js/zip.js": "2.4.20", "adm-zip": "^0.5.12", "axios": "^1.12.2", - "blockly": "12.3.1", + "blockly": "13.0.0-beta.5", "browserify": "17.0.0", "chai": "^3.5.0", "chalk": "^4.1.2", @@ -151,6 +150,12 @@ "yargs": "^17.7.2" }, "overrides": { + "@blockly/field-grid-dropdown": { + "blockly": "13.0.0-beta.5" + }, + "@blockly/plugin-workspace-search": { + "blockly": "13.0.0-beta.5" + }, "combine-source-map": { "source-map": "0.4.4" }, diff --git a/pxtblocks/builtins/variables.ts b/pxtblocks/builtins/variables.ts index 64a465042892..931924d693d5 100644 --- a/pxtblocks/builtins/variables.ts +++ b/pxtblocks/builtins/variables.ts @@ -2,8 +2,6 @@ import * as Blockly from "blockly"; import { createFlyoutGroupLabel, createFlyoutHeadingLabel, mkVariableFieldBlock } from "../toolbox"; import { installBuiltinHelpInfo, setBuiltinHelpInfo } from "../help"; -export const CREATE_VAR_BTN_ID = 'create-variable-btn'; - export function initVariables() { let varname = lf("{id:var}item"); Blockly.Variables.flyoutCategory = flyoutCategory; @@ -242,8 +240,6 @@ function flyoutCategory(workspace: Blockly.WorkspaceSvg, useXml: boolean): Eleme const button = document.createElement('button') as HTMLElement; button.setAttribute('text', lf("Make a Variable...")); button.setAttribute('callbackKey', 'CREATE_VARIABLE'); - // This id is used to re-focus the create variable button after the dialog is closed. - button.setAttribute('id', CREATE_VAR_BTN_ID); workspace.registerButtonCallback('CREATE_VARIABLE', function (button) { Blockly.Variables.createVariableButtonHandler(button.getTargetWorkspace()); diff --git a/pxtblocks/contextMenu/blockItems.ts b/pxtblocks/contextMenu/blockItems.ts index 024daedb5c6e..4cd8ce9c725d 100644 --- a/pxtblocks/contextMenu/blockItems.ts +++ b/pxtblocks/contextMenu/blockItems.ts @@ -147,6 +147,7 @@ function registerDuplicate() { scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, id: 'blockDuplicate', weight: BlockContextWeight.Duplicate, + associatedKeyboardShortcut: Blockly.ShortcutItems.names.DUPLICATE, }; Blockly.ContextMenuRegistry.registry.register(duplicateOption); -} \ No newline at end of file +} diff --git a/pxtblocks/contextMenu/workspaceItems.ts b/pxtblocks/contextMenu/workspaceItems.ts index e65f26d4e37c..5560263a18b2 100644 --- a/pxtblocks/contextMenu/workspaceItems.ts +++ b/pxtblocks/contextMenu/workspaceItems.ts @@ -54,6 +54,8 @@ function registerFormatCode() { scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, id: 'pxtFormatCode', weight: WorkspaceContextWeight.FormatCode, + // Re-registered with key F by webapp blocks.tsx initKeyboardControls. + associatedKeyboardShortcut: Blockly.ShortcutItems.names.CLEANUP, }; Blockly.ContextMenuRegistry.registry.register(formatOption); } @@ -290,4 +292,4 @@ function registerFind() { weight: WorkspaceContextWeight.Find, }; Blockly.ContextMenuRegistry.registry.register(findOption); -} \ No newline at end of file +} diff --git a/pxtblocks/copyPaste.ts b/pxtblocks/copyPaste.ts index 05a8333b8ef9..4f78a40e5a3e 100644 --- a/pxtblocks/copyPaste.ts +++ b/pxtblocks/copyPaste.ts @@ -8,7 +8,7 @@ let oldCopy: Blockly.ShortcutRegistry.KeyboardShortcut; let oldCut: Blockly.ShortcutRegistry.KeyboardShortcut; let oldPaste: Blockly.ShortcutRegistry.KeyboardShortcut; -export function initCopyPaste(accessibleBlocksEnabled: boolean, forceRefresh: boolean = false) { +export function initCopyPaste(forceRefresh: boolean = false) { if (!getCopyPasteHandlers()) return; if (oldCopy && !forceRefresh) return; @@ -27,65 +27,8 @@ export function initCopyPaste(accessibleBlocksEnabled: boolean, forceRefresh: bo registerCut(); registerPaste(); - if (!accessibleBlocksEnabled) { - registerCopyContextMenu(); - registerPasteContextMenu(); - } -} - -export function initAccessibleBlocksCopyPasteContextMenu() { - overridePasteContextMenuItem(); - overrideCutContextMenuItem(); -} - -function overridePasteContextMenuItem() { - const oldPasteOption = Blockly.ContextMenuRegistry.registry.getItem("blockPasteFromContextMenu"); - - if ("separator" in oldPasteOption) { - throw new Error(`RegistryItem ${oldPasteOption.id} is not of type ActionRegistryItem`); - }; - - const pasteOption: Blockly.ContextMenuRegistry.RegistryItem = { - ...oldPasteOption, - preconditionFn: pasteContextMenuPreconditionFn, - }; - - Blockly.ContextMenuRegistry.registry.unregister("blockPasteFromContextMenu"); - Blockly.ContextMenuRegistry.registry.register(pasteOption); -} - -function overrideCutContextMenuItem() { - const oldCutOption = Blockly.ContextMenuRegistry.registry.getItem("blockCutFromContextMenu"); - - if ("separator" in oldCutOption) { - throw new Error(`RegistryItem ${oldCutOption.id} is not of type ActionRegistryItem`); - }; - - const cutOption: Blockly.ContextMenuRegistry.RegistryItem = { - ...oldCutOption, - preconditionFn: (scope: Blockly.ContextMenuRegistry.Scope) => { - const focused = scope.focusedNode; - if (!focused || !Blockly.isCopyable(focused)) return "hidden"; - - const workspace = focused.workspace; - - if (focused.workspace.isFlyout) - return "hidden"; - - if (!(workspace instanceof Blockly.WorkspaceSvg)) return 'hidden'; - - if ( - oldCut.preconditionFn(workspace, scope) - ) { - return 'enabled'; - } - - return "hidden"; - }, - }; - - Blockly.ContextMenuRegistry.registry.unregister("blockCutFromContextMenu"); - Blockly.ContextMenuRegistry.registry.register(cutOption); + registerCopyContextMenu(); + registerPasteContextMenu(); } function registerCopy() { @@ -167,7 +110,8 @@ function registerCopyContextMenu() { }, scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, weight: BlockContextWeight.Copy, - id: "makecode-copy-block" + id: "makecode-copy-block", + associatedKeyboardShortcut: Blockly.ShortcutItems.names.COPY, }; const copyCommentOption: Blockly.ContextMenuRegistry.RegistryItem = { @@ -196,7 +140,8 @@ function registerCopyContextMenu() { }, scopeType: Blockly.ContextMenuRegistry.ScopeType.COMMENT, weight: BlockContextWeight.Copy, - id: "makecode-copy-comment" + id: "makecode-copy-comment", + associatedKeyboardShortcut: Blockly.ShortcutItems.names.COPY, }; if (Blockly.ContextMenuRegistry.registry.getItem(copyOption.id)) { @@ -222,7 +167,8 @@ function registerPasteContextMenu() { }, scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, weight: WorkspaceContextWeight.Paste, - id: "makecode-paste" + id: "makecode-paste", + associatedKeyboardShortcut: Blockly.ShortcutItems.names.PASTE, }; if (Blockly.ContextMenuRegistry.registry.getItem(pasteOption.id)) { @@ -286,4 +232,4 @@ function runCopyPreconditionFunction( updateDuplicateOnDragState(toCopy as Blockly.BlockSvg); return result; -} \ No newline at end of file +} diff --git a/pxtblocks/fields/field_asset.ts b/pxtblocks/fields/field_asset.ts index 38ece713c996..8a7c1d7554f9 100644 --- a/pxtblocks/fields/field_asset.ts +++ b/pxtblocks/fields/field_asset.ts @@ -242,7 +242,6 @@ export abstract class FieldAssetEditor injectDivBounds.width - toolboxWidth) { widgetDiv.style.width = ""; @@ -256,16 +255,16 @@ export abstract class FieldAssetEditor workspaceLeft) { + if (blockLeft.x - divBounds.width - 20 > toolboxWidth) { widgetDiv.style.left = (blockLeft.x - divBounds.width - 20) + "px" } else { // As a last resort, just center on the inject div - widgetDiv.style.left = (workspaceLeft + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; + widgetDiv.style.left = (toolboxWidth + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; } } else if (divBounds.left < injectDivBounds.left) { - widgetDiv.style.left = workspaceLeft + "px" + widgetDiv.style.left = toolboxWidth + "px" } } diff --git a/pxtblocks/fields/field_ledmatrix.ts b/pxtblocks/fields/field_ledmatrix.ts index 6cf968455e70..ab6bd1b19146 100644 --- a/pxtblocks/fields/field_ledmatrix.ts +++ b/pxtblocks/fields/field_ledmatrix.ts @@ -142,13 +142,14 @@ export class FieldLedMatrix extends FieldMatrix implements FieldCustom { this.selected = [0, 0]; const matrixRect = this.matrixSvg.getBoundingClientRect(); + const injectDivBounds = (this.getSourceBlock().workspace as Blockly.WorkspaceSvg).getInjectionDiv().getBoundingClientRect(); const widgetDiv = Blockly.WidgetDiv.getDiv(); widgetDiv.append(this.matrixSvg); this.addKeyboardFocusHandlers(); - widgetDiv.style.left = matrixRect.left + "px"; - widgetDiv.style.top = matrixRect.top + "px"; + widgetDiv.style.left = matrixRect.left - injectDivBounds.left + "px"; + widgetDiv.style.top = matrixRect.top - injectDivBounds.top + "px"; widgetDiv.style.transform = `scale(${(Blockly.getMainWorkspace() as Blockly.WorkspaceSvg).getScale()})`; widgetDiv.style.transformOrigin = "0 0"; @@ -486,4 +487,4 @@ Blockly.Css.register(` } .blocklyFieldLedMatrixGroup > .blocklyFieldRect { fill: none !important; -}`); \ No newline at end of file +}`); diff --git a/pxtblocks/fields/field_sound_effect.ts b/pxtblocks/fields/field_sound_effect.ts index a03bbe80c9a9..d248ce783fe6 100644 --- a/pxtblocks/fields/field_sound_effect.ts +++ b/pxtblocks/fields/field_sound_effect.ts @@ -245,14 +245,13 @@ export class FieldSoundEffect extends FieldBase { const blockLeft = workspaceToScreenCoordinates(block.workspace as Blockly.WorkspaceSvg, new Blockly.utils.Coordinate(bounds.left, bounds.top)); - const workspaceLeft = injectDivBounds.left + toolboxWidth; - if (blockLeft.x - divBounds.width - 20 > workspaceLeft) { + if (blockLeft.x - divBounds.width - 20 > toolboxWidth) { widgetDiv.style.left = (blockLeft.x - divBounds.width - 20) + "px" } else { // As a last resort, just center on the inject div - widgetDiv.style.left = (workspaceLeft + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; + widgetDiv.style.left = (toolboxWidth + ((injectDivBounds.width - toolboxWidth) / 2) - divBounds.width / 2) + "px"; } } } @@ -442,4 +441,4 @@ function isTrue(value: any) { } return !!value; -} \ No newline at end of file +} diff --git a/pxtblocks/fields/field_utils.ts b/pxtblocks/fields/field_utils.ts index 227252fff44d..e8aea9972687 100644 --- a/pxtblocks/fields/field_utils.ts +++ b/pxtblocks/fields/field_utils.ts @@ -582,16 +582,8 @@ export function workspaceToScreenCoordinates(ws: Blockly.WorkspaceSvg, wsCoordin const clientOffsetPixels = Blockly.utils.Coordinate.sum( scaledWS, mainOffsetPixels); - - const injectionDiv = ws.getInjectionDiv(); - - // Bounding rect coordinates are in client coordinates, meaning that they - // are in pixels relative to the upper left corner of the visible browser - // window. These coordinates change when you scroll the browser window. - const boundingRect = injectionDiv.getBoundingClientRect(); - - return new Blockly.utils.Coordinate(clientOffsetPixels.x + boundingRect.left, - clientOffsetPixels.y + boundingRect.top) + return new Blockly.utils.Coordinate(clientOffsetPixels.x, + clientOffsetPixels.y) } export function getBlockData(block: Blockly.Block): PXTBlockData { @@ -720,4 +712,4 @@ export function isImageProperties(obj: any): obj is Blockly.ImageProperties { 'height' in obj && typeof obj.height === 'number' ); -} \ No newline at end of file +} diff --git a/pxtblocks/loader.ts b/pxtblocks/loader.ts index d8d5384f0cc8..60dc6165945a 100644 --- a/pxtblocks/loader.ts +++ b/pxtblocks/loader.ts @@ -24,7 +24,7 @@ import { initContextMenu } from "./contextMenu"; import { renderCodeCard } from "./codecardRenderer"; import { FieldDropdown } from "./fields/field_dropdown"; import { setDraggableShadowBlocks, setDuplicateOnDrag, setDuplicateOnDragStrategy } from "./plugins/duplicateOnDrag"; -import { initAccessibleBlocksCopyPasteContextMenu, initCopyPaste } from "./copyPaste"; +import { initCopyPaste } from "./copyPaste"; export { initCopyPaste } from "./copyPaste"; import { FieldVariable } from "./plugins/newVariableField/fieldVariable"; import { ArgumentReporterBlock, FieldArgumentReporter, setArgumentReporterLocalizeFunction } from "./plugins/functions"; @@ -695,6 +695,7 @@ function init(blockInfo: pxtc.BlocksInfo) { initText(); initComments(); initTooltip(); + initAccessibilityMessages(); // in safari on ios, Blockly isn't always great at clearing touch // identifiers. for most browsers this doesn't matter because the @@ -712,10 +713,6 @@ function init(blockInfo: pxtc.BlocksInfo) { } } -export function initAccessibleBlocksContextMenuItems() { - initAccessibleBlocksCopyPasteContextMenu() -} - /** * Converts a TypeScript type into an array of type checks for Blockly inputs/outputs. Use @@ -787,6 +784,132 @@ function initComments() { Blockly.Msg.WORKSPACE_COMMENT_DEFAULT_TEXT = ''; } +function initAccessibilityMessages() { + // Translatable overrides for Blockly's built-in keyboard-navigation strings. + // Excludes text used only in the shortcut dialog that we don't use. + Object.assign(Blockly.Msg, { + // Action labels. + EDIT_BLOCK_CONTENTS: lf("Edit Block contents"), + MOVE_BLOCK: lf("Move Block"), + // Modifier and key names — read by Blockly's shortcut formatter when + // rendering shortcut hints (e.g. in the move/copy hint toasts). + CONTROL_KEY: lf("{id:keyboard symbol}Ctrl"), + COMMAND_KEY: lf("{id:keyboard symbol}⌘ Command"), + OPTION_KEY: lf("{id:keyboard symbol}⌥ Option"), + ALT_KEY: lf("{id:keyboard symbol}Alt"), + ENTER_KEY: lf("{id:keyboard symbol}Enter"), + BACKSPACE_KEY: lf("{id:keyboard symbol}Backspace"), + DELETE_KEY: lf("{id:keyboard symbol}Delete"), + ESCAPE: lf("{id:keyboard symbol}Esc"), + TAB_KEY: lf("{id:keyboard symbol}Tab"), + SHIFT_KEY: lf("{id:keyboard symbol}Shift"), + CAPS_LOCK_KEY: lf("{id:keyboard symbol}Caps Lock"), + SPACE_KEY: lf("{id:keyboard symbol}Space"), + PAGE_UP_KEY: lf("{id:keyboard symbol}Page Up"), + PAGE_DOWN_KEY: lf("{id:keyboard symbol}Page Down"), + END_KEY: lf("{id:keyboard symbol}End"), + HOME_KEY: lf("{id:keyboard symbol}Home"), + INSERT_KEY: lf("{id:keyboard symbol}Insert"), + PAUSE_KEY: lf("{id:keyboard symbol}Pause"), + CONTEXT_MENU_KEY: lf("{id:keyboard symbol}≣ Menu"), + UNNAMED_KEY: lf("{id:keyboard symbol}unnamed"), + // Menu labels for the copy/cut/paste shortcut metadata. + CUT_SHORTCUT: lf("Cut"), + COPY_SHORTCUT: lf("Copy"), + PASTE_SHORTCUT: lf("Paste"), + // Keyboard nav hints (toasts). + HELP_PROMPT: lf("Press %1 for help on keyboard controls."), + KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT: lf("Hold %1 and use arrow keys to move freely, then %2 to accept the position."), + KEYBOARD_NAV_CONSTRAINED_MOVE_HINT: lf("Use the arrow keys to move, then %1 to accept the position."), + KEYBOARD_NAV_COPIED_HINT: lf("Copied. Press %1 to paste."), + KEYBOARD_NAV_CUT_HINT: lf("Cut. Press %1 to paste."), + KEYBOARD_NAV_BLOCK_NAVIGATION_HINT: lf("Use %1 to navigate inside of blocks."), + KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT: lf("Use the arrow keys to navigate."), + KEYBOARD_NAV_FLYOUT_LABEL_HINT: lf("Use the arrow keys to navigate to a block, or press %1 to go to the next heading."), + // Aria labels for the workspace tree. + WORKSPACE_LABEL_1_STACK: lf("Blocks workspace. 1 stack of blocks"), + WORKSPACE_LABEL_MANY_STACKS: lf("Blocks workspace. %1 stacks of blocks"), + WORKSPACE_LABEL_MUTATOR_WORKSPACE: lf("Block editor workspace"), + WORKSPACE_LABEL_FLYOUT_WORKSPACE: lf("%1 blocks"), + // Workspace contents announcement (the 'I' announce-info shortcut). + WORKSPACE_CONTENTS_BLOCKS_ZERO: lf("No blocks%2 in workspace."), + WORKSPACE_CONTENTS_BLOCKS_ONE: lf("One stack of blocks%2 in workspace."), + WORKSPACE_CONTENTS_BLOCKS_MANY: lf("%1 stacks of blocks%2 in workspace."), + WORKSPACE_CONTENTS_COMMENTS_ONE: lf(" and one comment"), + WORKSPACE_CONTENTS_COMMENTS_MANY: lf(" and %1 comments"), + // Aria labels for blocks. + BLOCK_LABEL_BEGIN_STACK: lf("Begin stack"), + BLOCK_LABEL_BEGIN_PREFIX: lf("Begin %1"), + BLOCK_LABEL_TOOLBOX_CATEGORY: lf("%1 category"), + BLOCK_LABEL_DISABLED: lf("{id:block state}disabled"), + BLOCK_LABEL_COLLAPSED: lf("{id:block state}collapsed"), + BLOCK_LABEL_REPLACEABLE: lf("{id:block state}replaceable"), + BLOCK_LABEL_HAS_INPUT: lf("has input"), + BLOCK_LABEL_HAS_INPUTS: lf("has inputs"), + BLOCK_LABEL_HAS_BRANCHES: lf("has %1 branches"), + BLOCK_LABEL_STATEMENT: lf("{id:block role}command"), + BLOCK_LABEL_CONTAINER: lf("{id:block role}container"), + BLOCK_LABEL_VALUE: lf("{id:block role}value"), + BLOCK_LABEL_STACK_BLOCKS: lf("%1 stack blocks"), + // Aria labels for inputs. + INPUT_LABEL_INDEX: lf("input %1"), + INPUT_LABEL_VALUE: lf("{id:block position}value position"), + INPUT_LABEL_STATEMENT: lf("{id:block position}command position"), + INPUT_LABEL_END_STATEMENT: lf("End %1"), + INPUT_LABEL_EMPTY: lf("Empty"), + // Generic aria labels. + ARIA_LABEL_BUTTON: lf("button"), + ARIA_LABEL_COMMENT: lf("Comment"), + ARIA_LABEL_COMMENT_COLLAPSE: lf("Collapse Comment"), + ARIA_LABEL_COMMENT_EXPAND: lf("Expand Comment"), + ARIA_LABEL_HEADING: lf("heading"), + // Field type labels for screen readers. + ARIA_TYPE_FIELD_CHECKBOX: lf("checkbox"), + ARIA_TYPE_FIELD_DROPDOWN: lf("dropdown"), + ARIA_TYPE_FIELD_IMAGE: lf("image"), + ARIA_TYPE_FIELD_INPUT: lf("{id:field type}input"), + ARIA_TYPE_FIELD_NUMBER: lf("{id:field type}number"), + ARIA_TYPE_FIELD_TEXT_INPUT: lf("{id:field type}text"), + // Field state labels. + FIELD_LABEL_CHECKBOX_CHECKED: lf("{id:checkbox}Checked"), + FIELD_LABEL_CHECKBOX_UNCHECKED: lf("{id:checkbox}Not checked"), + FIELD_LABEL_EDIT_PREFIX: lf("Edit %1"), + FIELD_LABEL_EMPTY: lf("empty"), + FIELD_LABEL_OPTION_INDEX: lf("{id:dropdown}Option %1"), + FIELD_LABEL_VARIABLE: lf("Variable '%1'"), + // Bubble labels. + BUBBLE_LABEL_COMMENT: lf("Comment: %1"), + BUBBLE_LABEL_DEFAULT: lf("{id:speech bubble}Bubble"), + BUBBLE_LABEL_WARNING: lf("Warning: %1"), + // Icon labels. + ICON_LABEL_COMMENT_CLOSED: lf("Open Comment"), + ICON_LABEL_COMMENT_OPEN: lf("Close Comment"), + ICON_LABEL_DEFAULT: lf("Icon"), + ICON_LABEL_MUTATOR_CLOSED: lf("Edit this block"), + ICON_LABEL_MUTATOR_OPEN: lf("Close block editor"), + ICON_LABEL_WARNING_CLOSED: lf("Open Warning"), + ICON_LABEL_WARNING_OPEN: lf("Close Warning"), + // Move-mode announcements. + ANNOUNCE_MOVE_WORKSPACE: lf("Moving %1 on workspace."), + ANNOUNCE_MOVE_BEFORE: lf("Moving %1 before %2."), + ANNOUNCE_MOVE_AFTER: lf("Moving %1 after %2."), + ANNOUNCE_MOVE_INSIDE: lf("Moving %1 inside %2."), + ANNOUNCE_MOVE_AROUND: lf("Moving %1 around %2."), + ANNOUNCE_MOVE_TO: lf("Moving %1 to %2."), + ANNOUNCE_MOVE_OF: lf("%1 of %2"), + ANNOUNCE_MOVE_CANCELED: lf("Canceled movement."), + // Block info announcements (the 'I' / Shift+I shortcuts). + CURRENT_BLOCK_ANNOUNCEMENT: lf("Current block: %1"), + PARENT_BLOCKS_ANNOUNCEMENT: lf("Parent blocks: %1"), + NO_PARENT_ANNOUNCEMENT: lf("Current block has no parent"), + // Screenreader mode toggle (Cmd/Ctrl+Alt+Z). + SCREENREADER_MODE_ENABLED: lf("Screenreader mode is on, press %1 to turn it off"), + SCREENREADER_MODE_DISABLED: lf("Screenreader mode is off, press %1 to turn it on"), + // Used for Blockly's toast close aria label. + CLOSE: lf("Close"), + }); +} + function initTooltip() { const renderTip = (el: any) => { if (el.hasDisabledReason?.(AUTO_DISABLED_REASON)) @@ -986,4 +1109,4 @@ function localizeArgumentReporter(blocksInfo: pxtc.BlocksInfo, field: FieldArgum } return result; -} \ No newline at end of file +} diff --git a/pxtblocks/monkeyPatches/gesture.ts b/pxtblocks/monkeyPatches/gesture.ts index 5a5d7ab46775..e7c138be3379 100644 --- a/pxtblocks/monkeyPatches/gesture.ts +++ b/pxtblocks/monkeyPatches/gesture.ts @@ -1,10 +1,33 @@ import * as Blockly from "blockly"; +import { isAllowlistedShadow } from "../plugins/duplicateOnDrag/duplicateOnDrag"; +import { assertMethod } from "./util"; interface PatchedGesture extends Blockly.Gesture { id: number | undefined boundEvents_: Blockly.browserEvents.Data[]; } +/** + * Make allowlisted shadow blocks (marked `duplicateShadowOnDrag`) the drag + * target rather than their parent. Blockly's default walks up to the nearest + * non-shadow ancestor; we want the shadow itself so the duplicate-on-drag + * strategy can extract it and refill the parent slot via setShadowDom. + */ +export function monkeyPatchShadowDragTargetBlock() { + const proto = Blockly.Gesture.prototype as any; + assertMethod(proto, "setTargetBlock"); + const origSetTargetBlock = proto.setTargetBlock; + proto.setTargetBlock = function (block: Blockly.BlockSvg) { + if (block.isShadow() && isAllowlistedShadow(block)) { + this.targetBlock = block; + block.bringToFront(); + Blockly.getFocusManager().focusNode(block); + return; + } + return origSetTargetBlock.call(this, block); + }; +} + export function monkeyPatchGesture() { // This monkey patch is only required for the in-game experience of Minecraft on ChromeOS. // For some reason, events are occasionally dropped by the Android webview when multitouch @@ -111,4 +134,4 @@ export function monkeyPatchGesture() { this.boundEvents_.length = 0; } } -} \ No newline at end of file +} diff --git a/pxtblocks/monkeyPatches/index.ts b/pxtblocks/monkeyPatches/index.ts index cc61606ff3e5..5f25bdfaeae3 100644 --- a/pxtblocks/monkeyPatches/index.ts +++ b/pxtblocks/monkeyPatches/index.ts @@ -1,6 +1,6 @@ import { monkeyPatchBlockSvg } from "./blockSvg"; import { monkeyPatchConnection } from "./connection"; -import { monkeyPatchGesture } from "./gesture"; +import { monkeyPatchGesture, monkeyPatchShadowDragTargetBlock } from "./gesture"; import { monkeyPatchGrid } from "./grid"; import { monkeyPatchAddKeyMapping } from "./shortcut_registry"; @@ -8,6 +8,7 @@ export function applyMonkeyPatches() { monkeyPatchBlockSvg(); monkeyPatchGrid(); monkeyPatchGesture(); + monkeyPatchShadowDragTargetBlock(); monkeyPatchAddKeyMapping(); monkeyPatchConnection(); -} \ No newline at end of file +} diff --git a/pxtblocks/monkeyPatches/util.ts b/pxtblocks/monkeyPatches/util.ts new file mode 100644 index 000000000000..4480d568bd12 --- /dev/null +++ b/pxtblocks/monkeyPatches/util.ts @@ -0,0 +1,6 @@ +// Asserts a method exists on the target before we monkey patch it. +export function assertMethod(target: any, key: string) { + if (typeof target?.[key] !== "function") { + throw new Error(`Blockly monkey patch target missing method: ${key}`); + } +} diff --git a/pxtblocks/plugins/comments/bubble.ts b/pxtblocks/plugins/comments/bubble.ts index de34f6222ce6..16e66d64bc5b 100644 --- a/pxtblocks/plugins/comments/bubble.ts +++ b/pxtblocks/plugins/comments/bubble.ts @@ -7,7 +7,7 @@ import dom = Blockly.utils.dom; * bubble, where it has a "tail" that points to the block, and a "head" that * displays arbitrary svg elements. */ -export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blockly.ISelectable { +export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blockly.ISelectable, Blockly.IFocusableNode { /** The width of the border around the bubble. */ static readonly BORDER_WIDTH = 0; @@ -51,7 +51,7 @@ export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blo /** The position of the left of the bubble realtive to its anchor. */ private relativeLeft = 0; - private dragStrategy = new Blockly.dragging.BubbleDragStrategy(this, this.workspace); + private dragStrategy: Blockly.dragging.BubbleDragStrategy = new Blockly.dragging.BubbleDragStrategy(this, this.workspace); private focusableElement: SVGElement | HTMLElement; @@ -296,6 +296,18 @@ export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blo this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`); } + /** + * Moves the bubble by the given amounts in the x and y directions. + * + * @param dx The distance to move along the x axis. + * @param dy The distance to move along the y axis. + * @param _reason A description of why this move is happening. + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + const origin = this.getRelativeToSurfaceXY(); + this.moveTo(origin.x + dx, origin.y + dy); + } + /** * Positions the bubble "optimally" so that the most of it is visible and * it does not overlap the rect (if provided). @@ -566,6 +578,21 @@ export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blo ); } + /** + * Returns the bounds of this bubble. + * + * @returns A bounding box for this bubble. + */ + getBoundingRectangle(): Blockly.utils.Rect { + const origin = this.getRelativeToSurfaceXY(); + return new Blockly.utils.Rect( + origin.y, + origin.y + this.size.height, + origin.x, + origin.x + this.size.width, + ); + } + /** @internal */ getSvgRoot(): SVGElement { return this.svgRoot; @@ -618,8 +645,8 @@ export abstract class Bubble implements Blockly.IDeletable, Blockly.IBubble, Blo } /** Starts a drag on the bubble. */ - startDrag(): void { - this.dragStrategy.startDrag(); + startDrag() { + return this.dragStrategy.startDrag(); } /** Drags the bubble to the given location. */ @@ -756,4 +783,4 @@ Blockly.Css.register(` .blocklyBubble .blocklyTextarea.blocklyText { color: #575E75; } -`); \ No newline at end of file +`); diff --git a/pxtblocks/plugins/duplicateOnDrag/dragStrategy.ts b/pxtblocks/plugins/duplicateOnDrag/dragStrategy.ts index d59b3dc0f6b7..8038ff605273 100644 --- a/pxtblocks/plugins/duplicateOnDrag/dragStrategy.ts +++ b/pxtblocks/plugins/duplicateOnDrag/dragStrategy.ts @@ -5,485 +5,78 @@ */ import * as Blockly from "blockly"; - import { isAllowlistedShadow, shouldDuplicateOnDrag, updateDuplicateOnDragState } from "./duplicateOnDrag"; -import eventUtils = Blockly.Events; -import Coordinate = Blockly.utils.Coordinate; -import dom = Blockly.utils.dom; - -const BLOCK_LAYER = 50; // not exported by blockly - -/** Represents a nearby valid connection. */ -interface ConnectionCandidate { - /** A connection on the dragging stack that is compatible with neighbour. */ - local: Blockly.RenderedConnection; - /** A nearby connection that is compatible with local. */ - neighbour: Blockly.RenderedConnection; - - /** The distance between the local connection and the neighbour connection. */ - distance: number; +interface DragStrategyInternals { + block: Blockly.BlockSvg; + startChildConn: Blockly.Connection | null; } -export class DuplicateOnDragStrategy implements Blockly.IDragStrategy { - private workspace: Blockly.WorkspaceSvg; - - /** The parent block at the start of the drag. */ - private startParentConn: Blockly.RenderedConnection | null = null; - - /** - * The child block at the start of the drag. Only gets set if - * `healStack` is true. - */ - private startChildConn: Blockly.RenderedConnection | null = null; - - private startLoc: Coordinate | null = null; - - private connectionCandidate: ConnectionCandidate | null = null; - - private connectionPreviewer: Blockly.IConnectionPreviewer | null = null; - - private dragging = false; - - /** Used to persist an event group when snapping is done async. */ - private originalEventGroup = ''; - - /** - * If this is a shadow block, the offset between this block and the parent - * block, to add to the drag location. In workspace units. - */ - private dragOffset = new Coordinate(0, 0); - - constructor(private block: Blockly.BlockSvg) { - this.workspace = block.workspace; +// @ts-expect-error overriding private method +export class DuplicateOnDragStrategy extends Blockly.dragging.BlockDragStrategy { + protected getTargetBlock(): Blockly.BlockSvg { + const self = this as unknown as DragStrategyInternals; + // Keep the drag on an allowlisted shadow so disconnectBlock can extract + // it; otherwise Blockly's default would delegate the drag to the parent. + if (self.block.isShadow() && isAllowlistedShadow(self.block)) { + return self.block; } + return super.getTargetBlock(); + } - /** Returns true if the block is currently movable. False otherwise. */ - isMovable(): boolean { - if (this.block.isShadow()) { - return this.block.getParent()?.isMovable() ?? false; - } - - return ( - this.block.isOwnMovable() && - !this.block.isDeadOrDying() && - !this.workspace.options.readOnly && - // We never drag blocks in the flyout, only create new blocks that are - // dragged. - !this.block.isInFlyout - ); + override drag(newLoc: Blockly.utils.Coordinate, e?: PointerEvent | KeyboardEvent): void { + super.drag(newLoc, e); + // Workaround for https://github.com/RaspberryPiFoundation/blockly/issues/9898 + if (!e || e instanceof PointerEvent) { + const self = this as unknown as DragStrategyInternals; + self.block.moveDuringDrag(newLoc); } + } - /** - * Handles any setup for starting the drag, including disconnecting the block - * from any parent blocks. - */ - startDrag(e?: PointerEvent): void { - if (this.block.isShadow() && !isAllowlistedShadow(this.block)) { - this.startDraggingShadow(e); - return; - } + private disconnectBlock(healStack: boolean) { + const self = this as unknown as DragStrategyInternals; - this.dragging = true; - if (!eventUtils.getGroup()) { - eventUtils.setGroup(true); - } - this.fireDragStartEvent(); + let clone: Blockly.Block; + let target: Blockly.Connection; + let xml: Element; + const isShadow = self.block.isShadow(); - this.startLoc = this.block.getRelativeToSurfaceXY(); - - const previewerConstructor = Blockly.registry.getClassFromOptions( - Blockly.registry.Type.CONNECTION_PREVIEWER, - this.workspace.options, - ); - this.connectionPreviewer = new previewerConstructor!(this.block); - - // During a drag there may be a lot of rerenders, but not field changes. - // Turn the cache on so we don't do spurious remeasures during the drag. - dom.startTextWidthCache(); - this.workspace.setResizesEnabled(false); - Blockly.blockAnimations.disconnectUiStop(); - - const healStack = !!e && (e.altKey || e.ctrlKey || e.metaKey); - - if (this.shouldDisconnect(healStack)) { - this.disconnectBlock(healStack); - } - this.block.setDragging(true); - this.workspace.getLayerManager()?.moveToDragLayer(this.block); + if (isShadow) { + self.block.setShadow(false); } - /** Starts a drag on a shadow, recording the drag offset. */ - private startDraggingShadow(e?: PointerEvent) { - const parent = this.block.getParent(); - if (!parent) { - throw new Error( - 'Tried to drag a shadow block with no parent. ' + - 'Shadow blocks should always have parents.', - ); - } - this.dragOffset = Coordinate.difference( - parent.getRelativeToSurfaceXY(), - this.block.getRelativeToSurfaceXY(), - ); - parent.startDrag(e); - } + if (shouldDuplicateOnDrag(self.block)) { + const output = self.block.outputConnection; - /** - * Whether or not we should disconnect the block when a drag is started. - * - * @param healStack Whether or not to heal the stack after disconnecting. - * @returns True to disconnect the block, false otherwise. - */ - private shouldDisconnect(healStack: boolean): boolean { - return !!( - this.block.getParent() || - (healStack && - this.block.nextConnection && - this.block.nextConnection.targetBlock()) - ); - } + if (!output?.targetConnection) return; - /** - * Disconnects the block from any parents. If `healStack` is true and this is - * a stack block, we also disconnect from any next blocks and attempt to - * attach them to any parent. - * - * @param healStack Whether or not to heal the stack after disconnecting. - */ - private disconnectBlock(healStack: boolean) { - let clone: Blockly.Block; - let target: Blockly.Connection; - let xml: Element; - const isShadow = this.block.isShadow(); + xml = Blockly.Xml.blockToDom(self.block, true) as Element; - if (isShadow) { - this.block.setShadow(false); + if (!isShadow) { + clone = Blockly.Xml.domToBlock(xml, self.block.workspace); } - - if (shouldDuplicateOnDrag(this.block)) { - const output = this.block.outputConnection; - - if (!output?.targetConnection) return; - - xml = Blockly.Xml.blockToDom(this.block, true) as Element; - - if (!isShadow) { - clone = Blockly.Xml.domToBlock(xml, this.block.workspace); - } - target = output.targetConnection; - } - - this.startParentConn = - this.block.outputConnection?.targetConnection ?? - this.block.previousConnection?.targetConnection; - if (healStack) { - this.startChildConn = this.block.nextConnection?.targetConnection; - } - - if (target && isShadow) { - target.setShadowDom(xml) - } - - this.block.unplug(healStack); - Blockly.blockAnimations.disconnectUiEffect(this.block); - updateDuplicateOnDragState(this.block); - - if (target && clone) { - target.connect(clone.outputConnection); - } - } - - /** Fire a UI event at the start of a block drag. */ - private fireDragStartEvent() { - const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))( - this.block, - true, - this.block.getDescendants(false), - ); - eventUtils.fire(event); + target = output.targetConnection; } - /** Fire a UI event at the end of a block drag. */ - private fireDragEndEvent() { - const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))( - this.block, - false, - this.block.getDescendants(false), - ); - eventUtils.fire(event); + // Store startChildConn so revertDrag can rebuild [parent → block → next]; + // the base class only stores it when the block has no parent. + if (healStack) { + self.startChildConn = self.block.nextConnection?.targetConnection; } - /** Fire a move event at the end of a block drag. */ - private fireMoveEvent() { - if (this.block.isDeadOrDying()) return; - const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))( - this.block, - ) as Blockly.Events.BlockMove; - event.setReason(['drag']); - event.oldCoordinate = this.startLoc!; - event.recordNew(); - eventUtils.fire(event); + if (target && isShadow) { + target.setShadowDom(xml) } + self.block.unplug(healStack); + Blockly.blockAnimations.disconnectUiEffect(self.block); + updateDuplicateOnDragState(self.block); - /** Moves the block and updates any connection previews. */ - drag(newLoc: Coordinate): void { - if (this.block.isShadow()) { - this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset)); - return; - } - - this.block.moveDuringDrag(newLoc); - this.updateConnectionPreview( - this.block, - Coordinate.difference(newLoc, this.startLoc!), - ); - } - - /** - * @param draggingBlock The block being dragged. - * @param delta How far the pointer has moved from the position - * at the start of the drag, in workspace units. - */ - private updateConnectionPreview(draggingBlock: Blockly.BlockSvg, delta: Coordinate) { - const currCandidate = this.connectionCandidate; - const newCandidate = this.getConnectionCandidate(draggingBlock, delta); - if (!newCandidate) { - this.connectionPreviewer!.hidePreview(); - this.connectionCandidate = null; - return; - } - const candidate = - currCandidate && - this.currCandidateIsBetter(currCandidate, delta, newCandidate) - ? currCandidate - : newCandidate; - this.connectionCandidate = candidate; - - const { local, neighbour } = candidate; - const localIsOutputOrPrevious = - local.type === Blockly.ConnectionType.OUTPUT_VALUE || - local.type === Blockly.ConnectionType.PREVIOUS_STATEMENT; - const neighbourIsConnectedToRealBlock = - neighbour.isConnected() && !neighbour.targetBlock()!.isInsertionMarker(); - if ( - localIsOutputOrPrevious && - neighbourIsConnectedToRealBlock && - !this.orphanCanConnectAtEnd( - draggingBlock, - neighbour.targetBlock()!, - local.type, - ) - ) { - this.connectionPreviewer!.previewReplacement( - local, - neighbour, - neighbour.targetBlock()!, - ); - return; - } - this.connectionPreviewer!.previewConnection(local, neighbour); - } - - /** - * Returns true if the given orphan block can connect at the end of the - * top block's stack or row, false otherwise. - */ - private orphanCanConnectAtEnd( - topBlock: Blockly.BlockSvg, - orphanBlock: Blockly.BlockSvg, - localType: number, - ): boolean { - const orphanConnection = - localType === Blockly.ConnectionType.OUTPUT_VALUE - ? orphanBlock.outputConnection - : orphanBlock.previousConnection; - return !!Blockly.Connection.getConnectionForOrphanedConnection( - topBlock as Blockly.Block, - orphanConnection as Blockly.Connection, - ); - } - - /** - * Returns true if the current candidate is better than the new candidate. - * - * We slightly prefer the current candidate even if it is farther away. - */ - private currCandidateIsBetter( - currCandiate: ConnectionCandidate, - delta: Coordinate, - newCandidate: ConnectionCandidate, - ): boolean { - const { local: currLocal, neighbour: currNeighbour } = currCandiate; - const localPos = new Coordinate(currLocal.x, currLocal.y); - const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y); - const currDistance = Coordinate.distance( - Coordinate.sum(localPos, delta), - neighbourPos, - ); - return ( - newCandidate.distance > currDistance - Blockly.config.currentConnectionPreference - ); - } - - /** - * Returns the closest valid candidate connection, if one can be found. - * - * Valid neighbour connections are within the configured start radius, with a - * compatible type (input, output, etc) and connection check. - */ - private getConnectionCandidate( - draggingBlock: Blockly.BlockSvg, - delta: Coordinate, - ): ConnectionCandidate | null { - const localConns = this.getLocalConnections(draggingBlock); - let radius = this.connectionCandidate - ? Blockly.config.connectingSnapRadius - : Blockly.config.snapRadius; - let candidate = null; - - for (const conn of localConns) { - const { connection: neighbour, radius: rad } = conn.closest(radius, delta); - if (neighbour) { - candidate = { - local: conn, - neighbour: neighbour, - distance: rad, - }; - radius = rad; - } - } - - return candidate; - } - - /** - * Returns all of the connections we might connect to blocks on the workspace. - * - * Includes any connections on the dragging block, and any last next - * connection on the stack (if one exists). - */ - private getLocalConnections(draggingBlock: Blockly.BlockSvg): Blockly.RenderedConnection[] { - const available = draggingBlock.getConnections_(false); - const lastOnStack = draggingBlock.lastConnectionInStack(true); - if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) { - available.push(lastOnStack); - } - return available; - } - - /** - * Cleans up any state at the end of the drag. Applies any pending - * connections. - */ - endDrag(e?: PointerEvent): void { - if (this.block.isShadow()) { - this.block.getParent()?.endDrag(e); - return; - } - this.originalEventGroup = eventUtils.getGroup(); - - this.fireDragEndEvent(); - this.fireMoveEvent(); - - dom.stopTextWidthCache(); - - Blockly.blockAnimations.disconnectUiStop(); - this.connectionPreviewer!.hidePreview(); - - if (!this.block.isDeadOrDying() && this.dragging) { - // These are expensive and don't need to be done if we're deleting, or - // if we've already stopped dragging because we moved back to the start. - this.workspace - .getLayerManager() - ?.moveOffDragLayer(this.block, Blockly.layers.BLOCK); - this.block.setDragging(false); - } - - if (this.connectionCandidate) { - // Applying connections also rerenders the relevant blocks. - this.applyConnections(this.connectionCandidate); - this.disposeStep(); - } else { - this.block.queueRender().then(() => this.disposeStep()); - } - } - - /** Disposes of any state at the end of the drag. */ - private disposeStep() { - const newGroup = eventUtils.getGroup(); - eventUtils.setGroup(this.originalEventGroup); - this.block.snapToGrid(); - - // Must dispose after connections are applied to not break the dynamic - // connections plugin. See #7859 - this.connectionPreviewer!.dispose(); - this.workspace.setResizesEnabled(true); - eventUtils.setGroup(newGroup); - } - - /** Connects the given candidate connections. */ - private applyConnections(candidate: ConnectionCandidate) { - const { local, neighbour } = candidate; - local.connect(neighbour); - - const inferiorConnection = local.isSuperior() ? neighbour : local; - const rootBlock = this.block.getRootBlock(); - - Blockly.renderManagement.finishQueuedRenders().then(() => { - Blockly.blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock()); - // bringToFront is incredibly expensive. Delay until the next frame. - setTimeout(() => { - rootBlock.bringToFront(); - }, 0); - }); - } - - /** - * Moves the block back to where it was at the beginning of the drag, - * including reconnecting connections. - */ - revertDrag(): void { - if (this.block.isShadow()) { - this.block.getParent()?.revertDrag(); - return; - } - - this.startChildConn?.connect(this.block.nextConnection); - if (this.startParentConn) { - switch (this.startParentConn.type) { - case Blockly.ConnectionType.INPUT_VALUE: - this.startParentConn.connect(this.block.outputConnection); - break; - case Blockly.ConnectionType.NEXT_STATEMENT: - this.startParentConn.connect(this.block.previousConnection); - } - } else { - this.block.moveTo(this.startLoc!, ['drag']); - this.workspace - .getLayerManager() - ?.moveOffDragLayer(this.block, BLOCK_LAYER); - // Blocks dragged directly from a flyout may need to be bumped into - // bounds. - Blockly.bumpObjects.bumpIntoBounds( - this.workspace, - this.workspace.getMetricsManager().getScrollMetrics(true), - this.block, - ); - } - - this.startChildConn = null; - this.startParentConn = null; - - this.connectionPreviewer!.hidePreview(); - this.connectionCandidate = null; - - this.block.setDragging(false); - this.dragging = false; + if (target && clone) { + target.connect(clone.outputConnection); } + } } - export function setDuplicateOnDragStrategy(block: Blockly.Block | Blockly.BlockSvg) { (block as Blockly.BlockSvg).setDragStrategy?.(new DuplicateOnDragStrategy(block as Blockly.BlockSvg)); -} \ No newline at end of file +} diff --git a/pxtblocks/plugins/flyout/blockInflater.ts b/pxtblocks/plugins/flyout/blockInflater.ts index 2656ff06dbf3..9c55d8485332 100644 --- a/pxtblocks/plugins/flyout/blockInflater.ts +++ b/pxtblocks/plugins/flyout/blockInflater.ts @@ -43,6 +43,7 @@ export class MultiFlyoutRecyclableBlockInflater extends Blockly.BlockFlyoutInfla this.blockToKey.set(block, key); block.removeClass(HIDDEN_CLASS_NAME); + block.getFocusableElement().ariaHidden = "false"; block.setDisabledReason(false, HIDDEN_CLASS_NAME); return block; } @@ -73,6 +74,7 @@ export class MultiFlyoutRecyclableBlockInflater extends Blockly.BlockFlyoutInfla const xy = block.getRelativeToSurfaceXY(); block.moveBy(-xy.x, -xy.y); block.addClass(HIDDEN_CLASS_NAME); + block.getFocusableElement().ariaHidden = "true"; block.setDisabledReason(true, HIDDEN_CLASS_NAME); const key = this.blockToKey.get(block); this.keyToBlock.set(key, block); diff --git a/pxtblocks/plugins/flyout/buttonFlyoutInflater.ts b/pxtblocks/plugins/flyout/buttonFlyoutInflater.ts index 9513ae7b92d3..6d6ced90efbd 100644 --- a/pxtblocks/plugins/flyout/buttonFlyoutInflater.ts +++ b/pxtblocks/plugins/flyout/buttonFlyoutInflater.ts @@ -14,17 +14,12 @@ export class ButtonFlyoutInflater extends Blockly.ButtonFlyoutInflater { } load(state: object, flyout: Blockly.IFlyout): Blockly.FlyoutItem { - const modifiedState = state as Blockly.utils.toolbox.ButtonOrLabelInfo & {id?: string}; const button = new FlyoutButton( flyout.getWorkspace(), flyout.targetWorkspace!, - modifiedState, + state as Blockly.utils.toolbox.ButtonOrLabelInfo, false, ); - if (modifiedState.id) { - // This id is used to manage focus after dialog interactions. - button.getSvgRoot().setAttribute("id", modifiedState.id) - } button.show(); return new Blockly.FlyoutItem(button, BUTTON_TYPE); diff --git a/pxtblocks/plugins/newVariableField/fieldVariable.ts b/pxtblocks/plugins/newVariableField/fieldVariable.ts index bdbae9f58c63..bde7b56c9c80 100644 --- a/pxtblocks/plugins/newVariableField/fieldVariable.ts +++ b/pxtblocks/plugins/newVariableField/fieldVariable.ts @@ -39,7 +39,7 @@ export class FieldVariable extends Blockly.FieldVariable { if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { const id = menuItem.getValue(); if (id === FieldVariable.CREATE_VARIABLE_ID) { - Blockly.Variables.createVariableButtonHandler(this.sourceBlock_.workspace, name => { + Blockly.Variables.createVariableButtonHandler(this.sourceBlock_.workspace as Blockly.WorkspaceSvg, name => { const newVar = this.sourceBlock_.workspace.getVariableMap().getVariable(name); if (newVar) { diff --git a/pxtblocks/plugins/renderer/connectionPreviewer.ts b/pxtblocks/plugins/renderer/connectionPreviewer.ts index daca2b6905c3..dc3c259815ae 100644 --- a/pxtblocks/plugins/renderer/connectionPreviewer.ts +++ b/pxtblocks/plugins/renderer/connectionPreviewer.ts @@ -37,38 +37,70 @@ export class ConnectionPreviewer extends Blockly.InsertionMarkerPreviewer { const staticIndicatorParent = staticConn.sourceBlock_.getSvgRoot().querySelector(":scope>.blocklyConnectionIndicatorParent") as SVGElement; this.staticConnectionIndicator = this.createConnectionIndicator(staticIndicatorParent, staticConn); } - this.staticConnectionIndicator.parentElement.appendChild(this.staticConnectionIndicator); + this.raiseIndicators(); + this.updateLineCoords(draggedConn, staticConn); + // Refresh after the in-flight render queue drains. Covers two cases: + // - base.startDrag for keyboard events nudges the block after firing + // the preview, leaving our line endpoints stale. + // - disconnecting from a parent can queue a re-render of the static + // block (size change when a child slot collapses), shifting its + // connection positions. + Blockly.renderManagement.finishQueuedRenders().then(() => { + if (this.connectionLine) { + this.raiseIndicators(); + this.updateLineCoords(draggedConn, staticConn); + } + }); + } + + private raiseIndicators() { + // Dragged side: keep the dragged dot last in the dragged block's + // svgRoot so it paints over the line and the dragged block's path. + this.draggedConnectionIndicator.parentElement?.appendChild(this.draggedConnectionIndicator); + // Static side: ensure the indicator group ends up at the end of the + // static block's svgRoot so the connection-highlight path that + // RenderedConnection.highlight re-appends doesn't sit on top of it. + const staticParent = this.staticConnectionIndicator.parentElement; + staticParent.appendChild(this.staticConnectionIndicator); + staticParent.parentElement?.appendChild(staticParent); + } + private updateLineCoords(draggedConn: Blockly.RenderedConnection, staticConn: Blockly.RenderedConnection) { const radius = ConnectionPreviewer.CONNECTION_INDICATOR_RADIUS; - const offset = draggedConn.getOffsetInBlock(); + const dragOffset = draggedConn.getOffsetInBlock(); + const staticOffset = staticConn.getOffsetInBlock(); + // Connection offsets can shift when the host block re-renders (e.g. + // a static block whose input slot collapses on disconnect), so keep + // each indicator's transform in sync with the current offset. + this.draggedConnectionIndicator.setAttribute( + "transform", `translate(${dragOffset.x}, ${dragOffset.y})`); + this.staticConnectionIndicator.setAttribute( + "transform", `translate(${staticOffset.x}, ${staticOffset.y})`); const absDrag = Blockly.utils.Coordinate.sum( draggedConn.sourceBlock_.getRelativeToSurfaceXY(), - offset + dragOffset, ); const absStatic = Blockly.utils.Coordinate.sum( staticConn.sourceBlock_.getRelativeToSurfaceXY(), - staticConn.getOffsetInBlock() + staticOffset, ); - const dx = absStatic.x - absDrag.x; const dy = absStatic.y - absDrag.y; - // Offset the line by the radius of the indicator to prevent overlap - const atan = Math.atan2(dy, dx); - const len = Math.sqrt(dx * dx + dy * dy); - const isMouseDrag = Blockly.Gesture.inProgress(); - // When the indicators are overlapping, or if the drag is keyboard driven, we hide the line - if (len < radius * 2 + 1 || !isMouseDrag) { + // Hide when the indicators are close enough to overlap; the line + // endpoints would otherwise land inside the dots and look wrong. + if (len < radius * 2 + 1) { Blockly.utils.dom.addClass(this.connectionLine, "hidden"); - } else if (isMouseDrag) { - Blockly.utils.dom.removeClass(this.connectionLine, "hidden"); - this.connectionLine.setAttribute("x1", String(offset.x + Math.cos(atan) * radius)); - this.connectionLine.setAttribute("y1", String(offset.y + Math.sin(atan) * radius)); - - this.connectionLine.setAttribute("x2", String(offset.x + dx - Math.cos(atan) * radius)); - this.connectionLine.setAttribute("y2", String(offset.y + dy - Math.sin(atan) * radius)); + return; } + Blockly.utils.dom.removeClass(this.connectionLine, "hidden"); + // Stop each endpoint at the dot's edge so the line visually meets it. + const atan = Math.atan2(dy, dx); + this.connectionLine.setAttribute("x1", String(dragOffset.x + Math.cos(atan) * radius)); + this.connectionLine.setAttribute("y1", String(dragOffset.y + Math.sin(atan) * radius)); + this.connectionLine.setAttribute("x2", String(dragOffset.x + dx - Math.cos(atan) * radius)); + this.connectionLine.setAttribute("y2", String(dragOffset.y + dy - Math.sin(atan) * radius)); } hidePreview(): void { @@ -95,4 +127,4 @@ export class ConnectionPreviewer extends Blockly.InsertionMarkerPreviewer { 'translate(' + offset.x + ',' + offset.y + ')'); return result; } -} \ No newline at end of file +} diff --git a/pxtblocks/plugins/renderer/constants.ts b/pxtblocks/plugins/renderer/constants.ts index ec3dc122f552..2f459630eeec 100644 --- a/pxtblocks/plugins/renderer/constants.ts +++ b/pxtblocks/plugins/renderer/constants.ts @@ -49,8 +49,8 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { ellipses = this.makeEllipses(); - override createDom(svg: SVGElement, tagName: string, selector: string, injectionDivIfIsParent?: HTMLElement,): void { - super.createDom(svg, tagName, selector, injectionDivIfIsParent); + override createDom(svg: SVGElement, selector: string, injectionDivIfIsParent?: HTMLElement,): void { + super.createDom(svg, selector, injectionDivIfIsParent); const defs = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.DEFS, {}, svg); @@ -316,4 +316,4 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { throw Error('Unknown type'); } } -} \ No newline at end of file +} diff --git a/pxteditor/editorcontroller.ts b/pxteditor/editorcontroller.ts index fb5f9d7c4346..516aa437a294 100644 --- a/pxteditor/editorcontroller.ts +++ b/pxteditor/editorcontroller.ts @@ -258,8 +258,8 @@ export function bindEditorMessages(getEditorAsync: () => Promise) .then(() => projectView.toggleGreenScreen()); } case "togglekeyboardcontrols": { - return Promise.resolve() - .then(() => projectView.toggleAccessibleBlocks("editormessage")); + // Keyboard controls are always on; message kept for API compatibility. + return Promise.resolve(); } case "print": { return Promise.resolve() @@ -275,7 +275,7 @@ export function bindEditorMessages(getEditorAsync: () => Promise) versions: pxt.appTarget.versions, locale: ts.pxtc.Util.userLanguage(), availableLocales: pxt.appTarget.appTheme.availableLocales, - keyboardControls: projectView.isAccessibleBlocks() + keyboardControls: true } as pxt.editor.InfoMessage; }); } diff --git a/pxtlib/auth.ts b/pxtlib/auth.ts index 9e34f509d115..a4c05921610d 100644 --- a/pxtlib/auth.ts +++ b/pxtlib/auth.ts @@ -62,7 +62,6 @@ namespace pxt.auth { export type UserPreferences = { language?: string; highContrast?: boolean; - accessibleBlocks?: boolean; colorThemeIds?: ColorThemeIdsState; reader?: string; skillmap?: UserSkillmapState; @@ -73,7 +72,6 @@ namespace pxt.auth { export const DEFAULT_USER_PREFERENCES: () => UserPreferences = () => ({ language: pxt.appTarget.appTheme.defaultLocale, highContrast: false, - accessibleBlocks: undefined, // Defaulted at read time depending on flag colorThemeIds: {}, // Will lookup pxt.appTarget.appTheme.defaultColorTheme for active target reader: "", skillmap: { mapProgress: {}, completedTags: {} }, diff --git a/theme/blockly-core.less b/theme/blockly-core.less index 57d97c3061bc..c0ed47eb29cb 100644 --- a/theme/blockly-core.less +++ b/theme/blockly-core.less @@ -206,7 +206,6 @@ path.blocklyFlyoutBackground { *******************************/ div.blocklyWidgetDiv { - position: fixed; /* Lower Z index for BlocklyWidgetDiv and grid picker tooltips */ z-index: @blocklyWidgetDivZIndex; &:focus-visible { @@ -259,7 +258,7 @@ text.blocklyCheckbox { } /* (arcade only) Sets the correct background color for the sprite image field */ -.pxt-renderer.classic-theme { +body .pxt-renderer.classic-theme { .blocklyNonEditableText > rect.blocklySpriteField, .blocklyNonEditableText > rect.blocklyAnimationField, .blocklyNonEditableText > rect.blocklyTilemapField, @@ -271,7 +270,6 @@ text.blocklyCheckbox { stroke: #898989; stroke-width: 1; } - } /******************************* @@ -281,7 +279,7 @@ text.blocklyCheckbox { /* Transulcent blocks when dragging */ -.blocklyDragging>.blocklyPath { +body .blocklyDragging>.blocklyPath { fill-opacity: 0.7; } @@ -415,19 +413,19 @@ text.blocklyCheckbox { *******************************/ -.injectionDiv { - --blockly-active-node-color: #fff200; +// body prefix bumps specificity so --blockly-active-tree-color wins over +// Blockly's runtime-injected .injectionDiv default regardless of load order. +body .injectionDiv { --blockly-active-tree-color: var(--pxt-target-foreground1); - /* Comments are yellow default highlight is orange */ + // PXT-only var; matches the tree colour for contrast against the yellow node highlight. --blockly-active-workspace-comment-color: var(--pxt-target-foreground1); - --blockly-selection-width: 3px; } -// Avoid focus outlines when not using the keyboard navigation plugin. +// PXT's React-rendered toolbox tree. Suppress the default :focus-visible +// outline; the second rule shows a themed one when keyboard nav is active. div.blocklyTreeRoot > div[role="tree"]:focus-visible { outline: none; } - .blocklyKeyboardNavigation div.blocklyTreeRoot > div[role="tree"]:focus-visible { outline: var(--blockly-selection-width) solid var(--blockly-active-tree-color); outline-offset: calc(var(--blockly-selection-width) * -1); @@ -459,27 +457,21 @@ div.blocklyTreeRoot > div[role="tree"]:focus-visible { opacity: 1; } -/* Toolbox and flyout. */ +// Suppress Blockly's outline on the toolbox wrapper; we show the active-tree +// outline on the React-rendered tree above instead. .blocklyKeyboardNavigation .injectionDiv .blocklyToolbox:has(.blocklyActiveFocus) { outline: none; } -/* Flyout buttons and labels */ -.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus, -.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus { +// Outline the background rect rather than the text — our custom FlyoutButton +// extends the background to wrap the icon plus the text. +.blocklyKeyboardNavigation .injectionDiv .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelText { outline: none; } - -/* Use the backgrounds because the group can't have an outline on Safari */ -.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelBackground, -.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus > .blocklyFlyoutButtonBackground { +.blocklyKeyboardNavigation .injectionDiv .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelBackground { outline-offset: 2px; - outline: var(--blockly-selection-width) solid - var(--blockly-active-node-color); + outline: var(--blockly-selection-width) solid var(--blockly-active-node-color); border-radius: 2px; -} -.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelBackground { - // Swap opacity for transparent fill so we can see the focus indicator. opacity: 1; fill: transparent; } diff --git a/theme/melodyeditor.less b/theme/melodyeditor.less index e110e2182a33..63fae713e82c 100644 --- a/theme/melodyeditor.less +++ b/theme/melodyeditor.less @@ -43,7 +43,7 @@ background-color: #4f0643; } -.melody-content-div { +body .melody-content-div { max-height: 550px; width: 300px; padding: 0px; @@ -272,7 +272,7 @@ } .melody-content-div, -.pxt-renderer.classic-theme g.blocklyField > & { +body .pxt-renderer.classic-theme g.blocklyField > & { .melody-red { fill: #A80000; background: #A80000; diff --git a/webapp/src/accessibility.tsx b/webapp/src/accessibility.tsx index 04220036d351..ca7726279436 100644 --- a/webapp/src/accessibility.tsx +++ b/webapp/src/accessibility.tsx @@ -1,8 +1,6 @@ /// import * as React from "react"; -import * as auth from "./auth"; -import * as core from "./core"; import * as data from "./data"; import * as sui from "./sui"; @@ -31,11 +29,10 @@ export class EditorAccessibilityMenu extends data.Component) { - this.props.parent.openBlocks(core.isKeyboardControlsByDefault()); + this.props.parent.openBlocks(true); } openJavaScript() { @@ -63,10 +60,6 @@ export class EditorAccessibilityMenu extends data.Component(auth.ACCESSIBLE_BLOCKS); const menuClass = classList(targetTheme.invertedMenu && "inverted", "menu"); return
- {!accessibleBlocksOn && - - } - {accessibleBlocksOn && - - } + (auth.ACCESSIBLE_BLOCKS)) { - return; - } - switch (action) { case "escape": { this.setSimulatorFullScreen(false); @@ -1849,13 +1844,6 @@ export class ProjectView this.shouldTryDecompile = true; } - // Onboard accessible blocks if accessible blocks has just been enabled - const onboardAccessibleBlocks = pxt.storage.getLocal("onboardAccessibleBlocks") === "1" - const sideDocsLoadUrl = onboardAccessibleBlocks ? `${container.builtInPrefix}keyboardControls` : "" - if (onboardAccessibleBlocks) { - pxt.storage.setLocal("onboardAccessibleBlocks", "0") - } - // Force editor tools to collapse in headless tutorials and blocks mode (essentially hiding file explorer) const forceEditorToolsCollapse = pxt.appTarget.simulator.headless && (!!h.tutorial || h.editor === pxt.BLOCKS_PROJECT_NAME); @@ -1867,7 +1855,7 @@ export class ProjectView header: h, projectName: h.name, currFile: file, - sideDocsLoadUrl: sideDocsLoadUrl, + sideDocsLoadUrl: "", debugging: false, isMultiplayerGame: false, collapseEditorTools: forceEditorToolsCollapse || this.state.collapseEditorTools, @@ -4813,9 +4801,7 @@ export class ProjectView extensionsVisible: false }) - if (this.getData(auth.ACCESSIBLE_BLOCKS)) { - this.editor.focusToolbox(CategoryNameID.Extensions); - } + this.editor.focusToolbox(CategoryNameID.Extensions); } showPackageDialog() { @@ -5335,19 +5321,6 @@ export class ProjectView this.setState({ greenScreen: greenScreenOn }); } - async toggleAccessibleBlocks(eventSource: string) { - const nextEnabled = !this.getData(auth.ACCESSIBLE_BLOCKS); - if (nextEnabled) { - pxt.storage.setLocal("onboardAccessibleBlocks", "1") - } - await core.toggleAccessibleBlocks(eventSource) - this.reloadEditor(); - } - - isAccessibleBlocks(): boolean { - return this.getData(auth.ACCESSIBLE_BLOCKS); - } - setBannerVisible(b: boolean) { this.setState({ bannerVisible: b }); } @@ -5561,7 +5534,6 @@ export class ProjectView const inHome = this.state.home && !sandbox; const inEditor = !!this.state.header && !inHome; const { lightbox, greenScreen } = this.state; - const accessibleBlocks = this.getData(auth.ACCESSIBLE_BLOCKS) const hideTutorialIteration = inTutorial && tutorialOptions.metadata?.hideIteration; const hideToolbox = inTutorial && tutorialOptions.metadata?.hideToolbox; // flyoutOnly has become a de facto css class for styling tutorials (especially minecraft HOC), so keep it if hideToolbox is true, even if flyoutOnly is false. @@ -5651,7 +5623,7 @@ export class ProjectView header={this.state.header} reloadHeaderAsync={async () => { await this.reloadHeaderAsync() - this.shouldFocusToolbox = !!accessibleBlocks; + this.shouldFocusToolbox = true; }} /> } @@ -6516,12 +6488,6 @@ document.addEventListener("DOMContentLoaded", async () => { initHashchange(); socketbridge.tryInit(); electron.initElectron(theEditor); - pxt.tickEvent( - "accessibilty.accessibleBlocksEnabledForSession", - { - enabled: data.getData(auth.ACCESSIBLE_BLOCKS) ? "true" : "false", - } - ); }) .then(() => { const showHome = theEditor.shouldShowHomeScreen(); diff --git a/webapp/src/auth.ts b/webapp/src/auth.ts index 7925455f0ede..3bec060c1c3a 100644 --- a/webapp/src/auth.ts +++ b/webapp/src/auth.ts @@ -15,13 +15,11 @@ export const LOGGED_IN = `${MODULE}:${FIELD_LOGGED_IN}`; const USER_PREF_MODULE = "user-pref"; const FIELD_USER_PREFERENCES = "preferences"; const FIELD_HIGHCONTRAST = "high-contrast"; -const FIELD_KEYBOARD_CONTROLS = "keyboard-controls"; const FIELD_COLOR_THEME_IDS = "colorThemeIds"; const FIELD_LANGUAGE = "language"; const FIELD_READER = "reader"; export const USER_PREFERENCES = `${USER_PREF_MODULE}:${FIELD_USER_PREFERENCES}` export const HIGHCONTRAST = `${USER_PREF_MODULE}:${FIELD_HIGHCONTRAST}` -export const ACCESSIBLE_BLOCKS = `${USER_PREF_MODULE}:${FIELD_KEYBOARD_CONTROLS}` export const COLOR_THEME_IDS = `${USER_PREF_MODULE}:${FIELD_COLOR_THEME_IDS}` export const LANGUAGE = `${USER_PREF_MODULE}:${FIELD_LANGUAGE}` export const READER = `${USER_PREF_MODULE}:${FIELD_READER}` @@ -71,7 +69,6 @@ class AuthClient extends pxt.auth.AuthClient { switch (op.path.join('/')) { case "language": data.invalidate(LANGUAGE); break; case "highContrast": data.invalidate(HIGHCONTRAST); break; - case "accessibleBlocks": data.invalidate(ACCESSIBLE_BLOCKS); break; case "colorThemeIds": data.invalidate(COLOR_THEME_IDS); break; case "reader": data.invalidate(READER); break; } @@ -123,11 +120,6 @@ class AuthClient extends pxt.auth.AuthClient { // Identity not available, read from local storage switch (path) { case HIGHCONTRAST: return /^true$/i.test(pxt.storage.getLocal(HIGHCONTRAST)); - case ACCESSIBLE_BLOCKS: { - const stored = pxt.storage.getLocal(ACCESSIBLE_BLOCKS); - if (stored == null) return core.isKeyboardControlsByDefault(); - return /^true$/i.test(stored); - } case COLOR_THEME_IDS: return pxt.U.jsonTryParse(pxt.storage.getLocal(COLOR_THEME_IDS)) as pxt.auth.ColorThemeIdsState; case LANGUAGE: return pxt.storage.getLocal(LANGUAGE); case READER: return pxt.storage.getLocal(READER); @@ -144,7 +136,6 @@ class AuthClient extends pxt.auth.AuthClient { switch (field) { case FIELD_USER_PREFERENCES: return { ...state.preferences }; case FIELD_HIGHCONTRAST: return state.preferences?.highContrast ?? pxt.auth.DEFAULT_USER_PREFERENCES().highContrast; - case FIELD_KEYBOARD_CONTROLS: return state.preferences?.accessibleBlocks ?? core.isKeyboardControlsByDefault(); case FIELD_COLOR_THEME_IDS: return state.preferences?.colorThemeIds ?? pxt.auth.DEFAULT_USER_PREFERENCES().colorThemeIds; case FIELD_LANGUAGE: return state.preferences?.language ?? pxt.auth.DEFAULT_USER_PREFERENCES().language; case FIELD_READER: return state.preferences?.reader ?? pxt.auth.DEFAULT_USER_PREFERENCES().reader; @@ -258,32 +249,6 @@ export async function setHighContrastPrefAsync(highContrast: boolean): Promise { - const cli = await clientAsync(); - - pxt.tickEvent( - "auth.setAccessibleBlocks", - { - enabling: accessibleBlocks ? "true" : "false", - defaultOn: core.isKeyboardControlsByDefault() ? "true" : "false", - eventSource: eventSource, - local: !cli ? "true" : "false" - } - ); - - if (cli) { - await cli.patchUserPreferencesAsync({ - op: 'replace', - path: ['accessibleBlocks'], - value: accessibleBlocks - }, { immediate: true }); // sync this change immediately, as the page is about to reload. - } else { - // Identity not available, save this setting locally - pxt.storage.setLocal(ACCESSIBLE_BLOCKS, accessibleBlocks.toString()); - data.invalidate(ACCESSIBLE_BLOCKS); - } -} - export async function setThemePrefAsync(themeId: string): Promise { const cli = await clientAsync(); const targetId = pxt.appTarget.id; diff --git a/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index da324351dd49..39af345fddae 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -17,7 +17,6 @@ import { CreateFunctionDialog } from "./createFunction"; import { initializeSnippetExtensions } from './snippetBuilder'; import * as pxtblockly from "../../pxtblocks"; -import { KeyboardNavigation } from '@blockly/keyboard-navigation'; import { WorkspaceSearch } from "@blockly/plugin-workspace-search"; import Util = pxt.Util; @@ -38,9 +37,10 @@ import { PathObject } from "../../pxtblocks/plugins/renderer/pathObject"; import { Measurements } from "./constants"; import { flow, initCopyPaste } from "../../pxtblocks"; import { initContextMenu } from "../../pxtblocks/contextMenu"; +import { assertMethod } from "../../pxtblocks/monkeyPatches/util"; import { HIDDEN_CLASS_NAME } from "../../pxtblocks/plugins/flyout/blockInflater"; import { AIFooter } from "../../react-common/components/controls/AIFooter"; -import { CREATE_VAR_BTN_ID } from "../../pxtblocks/builtins/variables"; +import { getShortcutKeysShort, LIST_SHORTCUTS_SHORTCUT } from "./shortcut_formatting"; interface CopyDataEntry { version: 1; @@ -77,8 +77,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { protected highlightedStatement: pxtc.LocationInfo; // Blockly plugins - protected keyboardNavigation: KeyboardNavigation; - protected workspaceSearch: WorkspaceSearch; public nsMap: pxt.Map; @@ -160,16 +158,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { simulator.driver.setBreakpoints([]); } - handleKeyDown = (e: any) => { - if (this.parent.state?.accessibleBlocks) { - let charCode = (typeof e.which == "number") ? e.which : e.keyCode - if (charCode === 84 /* T Key */) { - this.focusToolbox(); - e.stopPropagation(); - } - } - } - setVisible(v: boolean) { super.setVisible(v); this.isVisible = v; @@ -535,8 +523,9 @@ export class Editor extends toolboxeditor.ToolboxEditor { /** * Move the toolbox to the edge. */ - const oldToolboxPosition = (Blockly as any).Toolbox.prototype.position; - (Blockly as any).Toolbox.prototype.position = function () { + assertMethod(Blockly.Toolbox.prototype, "position"); + const oldToolboxPosition = Blockly.Toolbox.prototype.position; + Blockly.Toolbox.prototype.position = function () { oldToolboxPosition.call(this); editor.resizeToolbox(); }; @@ -548,26 +537,44 @@ export class Editor extends toolboxeditor.ToolboxEditor { * it to select the first category and clear the selection. */ const that = this; + assertMethod(Blockly.FlyoutNavigator.prototype, "getOutNode"); + Blockly.FlyoutNavigator.prototype.getOutNode = function(_node?: Blockly.IFocusableNode | null, _bypassAdjustments = false) { + // Focus the React toolbox tree and return a non-null node so the + // left-arrow shortcut doesn't beep. Blockly's subsequent + // focusNode(firstItem) is a no-op because React replaced the stock + // toolbox DOM, so firstItem's element is detached from the document + // and .focus() on it does nothing. + const firstItem = that.editor.getToolbox().getToolboxItems()[0]; + Blockly.getFocusManager().focusNode(firstItem); + that.toolbox.focus(); + return firstItem; + } + assertMethod(Blockly.Toolbox.prototype, "getFocusableElement"); Blockly.Toolbox.prototype.getFocusableElement = function() { return that.getToolboxDiv()?.querySelector(".blocklyTreeRoot [role=tree]") as HTMLElement ?? that.getBlocksAreaDiv(); }; + assertMethod(Blockly.Toolbox.prototype, "getRestoredFocusableNode"); Blockly.Toolbox.prototype.getRestoredFocusableNode = function() { return null; }; + assertMethod(Blockly.Toolbox.prototype, "onTreeFocus"); Blockly.Toolbox.prototype.onTreeFocus = function() { return; }; + assertMethod(Blockly.Toolbox.prototype, "setSelectedItem"); Blockly.Toolbox.prototype.setSelectedItem = function (newItem: Blockly.ISelectableToolboxItem | null) { if (newItem === null) { that.hideFlyout(); } }; + assertMethod(Blockly.Toolbox.prototype, "clearSelection"); Blockly.Toolbox.prototype.clearSelection = function () { that.hideFlyout(); that.toolbox.clearExpandedItem(); }; + assertMethod(Blockly.Toolbox.prototype, "onTreeBlur"); const oldToolboxOnTreeBlur = Blockly.Toolbox.prototype.onTreeBlur; - (Blockly.Toolbox as any).prototype.onTreeBlur = function (nextTree: Blockly.IFocusableTree | null) { + Blockly.Toolbox.prototype.onTreeBlur = function (nextTree: Blockly.IFocusableTree | null) { // If the search box is focused and there are search results, the flyout is set to forceOpen. // Otherwise, the flyout closes and then re-opens causing an unpleasant visual effect. if ((that.editor.getFlyout() as pxtblockly.CachingFlyout).forceOpen) { @@ -578,12 +585,14 @@ export class Editor extends toolboxeditor.ToolboxEditor { } oldToolboxOnTreeBlur.call(this, nextTree); }; - (Blockly.WorkspaceSvg as any).prototype.refreshToolboxSelection = function () { + assertMethod(Blockly.WorkspaceSvg.prototype, "refreshToolboxSelection"); + Blockly.WorkspaceSvg.prototype.refreshToolboxSelection = function (this: any) { let ws = this.isFlyout ? this.targetWorkspace : this; if (ws && !ws.currentGesture_ && ws.toolbox_ && ws.toolbox_.flyout_) { that.toolbox.refreshSelection(); } }; + assertMethod(Blockly.WorkspaceSvg.prototype, "getRestoredFocusableNode"); const oldWorkspaceSvgGetRestoredFocusableNode = Blockly.WorkspaceSvg.prototype.getRestoredFocusableNode; Blockly.WorkspaceSvg.prototype.getRestoredFocusableNode = function (previousNode: Blockly.IFocusableNode | null) { // Specifically handle flyout case to work with the caching flyout implementation @@ -591,20 +600,16 @@ export class Editor extends toolboxeditor.ToolboxEditor { (!previousNode || that.isFlyoutItemDisposed(previousNode, previousNode instanceof Blockly.BlockSvg ? previousNode : null) || that.ignoreFlyoutPreviousNode) ) { const flyout = that.editor.getFlyout(); - const node = that.getDefaultFlyoutCursorIfNeeded(flyout); - if (node) { - const flyoutCursor = flyout.getWorkspace().getCursor(); - // Work around issue with a flyout label being the first item in the flyout. - // Set the cursor node here so the cursor doesn't fall back to last focused block. - flyoutCursor.setCurNode(node); - } + const navigator = flyout.getWorkspace().getNavigator(); + const node = navigator.getFirstNode(); that.ignoreFlyoutPreviousNode = false; return node; } return oldWorkspaceSvgGetRestoredFocusableNode.call(this, previousNode); }; + assertMethod(Blockly.WorkspaceSvg.prototype, "onTreeBlur"); const oldWorkspaceSvgOnTreeBlur = Blockly.WorkspaceSvg.prototype.onTreeBlur; - (Blockly.WorkspaceSvg as any).prototype.onTreeBlur = function (nextTree: Blockly.IFocusableNode | null): void { + Blockly.WorkspaceSvg.prototype.onTreeBlur = function (nextTree: Blockly.IFocusableTree | null): void { // Keep the flyout open whe a variable is created. if ((that.editor.getFlyout() as pxtblockly.CachingFlyout)?.forceOpen) { that.setFlyoutForceOpen(false); @@ -612,7 +617,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { } oldWorkspaceSvgOnTreeBlur.call(this, nextTree); }; - const oldHideChaff = (Blockly as any).hideChaff; + assertMethod(Blockly, "hideChaff"); + const oldHideChaff = Blockly.hideChaff; (Blockly as any).hideChaff = function (opt_allowToolbox?: boolean) { oldHideChaff(opt_allowToolbox); if (!opt_allowToolbox) that.hideFlyout(); @@ -622,8 +628,9 @@ export class Editor extends toolboxeditor.ToolboxEditor { private initWorkspaceSounds() { const editor = this; - const oldAudioPlay = (Blockly as any).WorkspaceAudio.prototype.play; - (Blockly as any).WorkspaceAudio.prototype.play = function (name: string, opt_volume?: number) { + assertMethod(Blockly.WorkspaceAudio.prototype, "play"); + const oldAudioPlay = Blockly.WorkspaceAudio.prototype.play; + Blockly.WorkspaceAudio.prototype.play = function (name: string, opt_volume?: number) { const themeVolume = pxt.appTarget?.appTheme?.blocklySoundVolume; if (editor?.parent.state.mute === MuteState.Muted) { @@ -637,103 +644,50 @@ export class Editor extends toolboxeditor.ToolboxEditor { opt_volume = themeVolume; } } - oldAudioPlay.call(this, name, opt_volume); + return oldAudioPlay.call(this, name, opt_volume); }; } - private unregisterBlocklyShortcutIfExists(shortcutName: string) { - if (Blockly.ShortcutRegistry.registry.getRegistry()[shortcutName]) { - Blockly.ShortcutRegistry.registry.unregister(shortcutName); - } - } - - private initAccessibleBlocks() { - if (!this.keyboardNavigation) { - // Keyboard navigation plugin (note message text is actually in Blockly) - // Excludes text used only in the shortcut dialog that we don't use. - Object.assign(Blockly.Msg, { - EDIT_BLOCK_CONTENTS: lf("Edit Block contents"), - MOVE_BLOCK: lf("Move Block"), - // Longer versions not used (COMMAND_KEY, OPTION_KEY), short ones used in hints. - CONTROL_KEY: lf("Ctrl"), - ALT_KEY: lf("Alt"), - CUT_SHORTCUT: lf("Cut"), - COPY_SHORTCUT: lf("Copy"), - PASTE_SHORTCUT: lf("Paste"), - HELP_PROMPT: lf("Press %1 for help on keyboard controls"), - KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT: lf("Hold %1 and use arrow keys to move anywhere, then %2 to accept the position"), - KEYBOARD_NAV_CONSTRAINED_MOVE_HINT: lf("Use the arrow keys to move, then %1 to accept the position"), - KEYBOARD_NAV_COPIED_HINT: lf("Copied. Press %1 to paste."), - KEYBOARD_NAV_CUT_HINT: lf("Cut. Press %1 to paste."), - // Used for Blocky's toast's close aria label. - CLOSE: lf("Close") - }); - - // Unregister shortcuts that will be re-created when the keyboard nav plugin registers - this.unregisterBlocklyShortcutIfExists("keyboard_nav_copy"); - this.unregisterBlocklyShortcutIfExists("keyboard_nav_cut"); - this.unregisterBlocklyShortcutIfExists("keyboard_nav_paste"); - - this.keyboardNavigation = new KeyboardNavigation(this.editor, { - allowCrossWorkspacePaste: true - }); - Blockly.keyboardNavigationController.setIsActive(true); - - const listShortcuts = Blockly.ShortcutRegistry.registry.getRegistry()["list_shortcuts"]; - Blockly.ShortcutRegistry.registry.unregister(listShortcuts.name); - Blockly.ShortcutRegistry.registry.register({ - ...listShortcuts, - keyCodes: [ - Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [ - Blockly.utils.KeyCodes.META, - ]), - Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [ - Blockly.utils.KeyCodes.CTRL, - ]), - ] - }); - - const cleanUpWorkspace = Blockly.ShortcutRegistry.registry.getRegistry()["clean_up_workspace"]; - Blockly.ShortcutRegistry.registry.unregister(cleanUpWorkspace.name); - Blockly.ShortcutRegistry.registry.register({ - ...cleanUpWorkspace, - // The default key is 'c' to "clean up workspace". Use 'f' instead to align with "format code". - keyCodes: [Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.F, null)], - callback: (workspace) => { - flow(workspace, { useViewWidth: true }); - return true - } - }); - - const startMoveShortcut = Blockly.ShortcutRegistry.registry.getRegistry()["start_move"]; - Blockly.ShortcutRegistry.registry.unregister(startMoveShortcut.name); - Blockly.ShortcutRegistry.registry.register({ - ...startMoveShortcut, - callback: (workspace, e, shortcut, scope) => { - maybeCloneBlockForMove(workspace); - - return startMoveShortcut.callback!(workspace, e, shortcut, scope); - } - }); - + private initKeyboardControls() { + Blockly.ShortcutRegistry.registry.register({ + name: LIST_SHORTCUTS_SHORTCUT, + callback: (workspace) => { + Blockly.Toast.hide(workspace, "helpHint"); + return true + }, + keyCodes: [ + Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [ + Blockly.utils.KeyCodes.META, + ]), + Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ] + }); - const startMoveContextMenuEntry = Blockly.ContextMenuRegistry.registry.getItem("move"); - Blockly.ContextMenuRegistry.registry.unregister(startMoveContextMenuEntry.id); - Blockly.ContextMenuRegistry.registry.register({ - ...startMoveContextMenuEntry, - callback: (scope: Blockly.ContextMenuRegistry.Scope, menuOpenEvent: Event, menuSelectEvent: Event, location: Blockly.utils.Coordinate) => { - maybeCloneBlockForMove(scope.block?.workspace || scope.workspace); + const cleanUpWorkspace = Blockly.ShortcutRegistry.registry.getRegistry()[Blockly.ShortcutItems.names.CLEANUP]; + Blockly.ShortcutRegistry.registry.unregister(cleanUpWorkspace.name); + Blockly.ShortcutRegistry.registry.register({ + ...cleanUpWorkspace, + name: Blockly.ShortcutItems.names.CLEANUP, + // The default key is 'c' to "clean up workspace". Use 'f' instead to align with "format code". + keyCodes: [Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.F, null)], + callback: (workspace) => { + flow(workspace, { useViewWidth: true }); + return true + } + }); - return startMoveContextMenuEntry.callback!(scope, menuOpenEvent, menuSelectEvent, location); - } - } as Blockly.ContextMenuRegistry.RegistryItem); + const startMoveShortcut = Blockly.ShortcutRegistry.registry.getRegistry()[Blockly.ShortcutItems.names.START_MOVE]; + Blockly.ShortcutRegistry.registry.unregister(startMoveShortcut.name); + Blockly.ShortcutRegistry.registry.register({ + ...startMoveShortcut, + callback: (workspace, e, shortcut, scope) => { + maybeCloneBlockForMove(workspace); - // This must come after plugin initialization to override context menu - // precondition functions set by the keyboard navigation plugin. - // We want to customize this behavior and have access to clipboard data to - // determined whether paste should be enabled. - pxtblockly.initAccessibleBlocksContextMenuItems(); - } + return startMoveShortcut.callback!(workspace, e, shortcut, scope); + } + }); } private initWorkspaceSearch() { @@ -816,7 +770,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { let shouldRestartSim = false; - this.editor.addChangeListener((ev: any) => { + this.editor.addChangeListener((ev: Blockly.Events.Abstract) => { Blockly.Events.disableOrphans(ev); const ignoredChanges = [ @@ -842,16 +796,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.showVariablesFlyout(); // Workspace onTreeBlur is not called if the var is created via mouse, so reset state. this.setFlyoutForceOpen(false); - - if (this.keyboardNavigation) { - const flyout = this.editor.getFlyout(); - const flyoutWorkspace = flyout.getWorkspace(); - const newCreateVarButtonNode = flyoutWorkspace.lookUpFocusableNode(CREATE_VAR_BTN_ID); - if (newCreateVarButtonNode) { - const flyoutCursor = flyout.getWorkspace().getCursor(); - flyoutCursor.setCurNode(newCreateVarButtonNode); - } - } } } @@ -860,33 +804,33 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.changeCallback(); this.markIncomplete = false; } - if (ev.type == Blockly.Events.CREATE) { - let blockId = ev.xml.getAttribute('type'); - if (blockId == "variables_set") { + if (ev.type == Blockly.Events.BLOCK_CREATE) { + const ws = ev.getEventWorkspace_(); + const block = ws?.getBlockById((ev as Blockly.Events.BlockCreate).blockId) + let blockType = block?.type; + if (blockType == "variables_set") { // Need to bump suffix in flyout this.clearFlyoutCaches(); } - if (blockId === pxtc.TS_STATEMENT_TYPE || blockId === pxtc.TS_OUTPUT_TYPE) { + if (blockType === pxtc.TS_STATEMENT_TYPE || blockType === pxtc.TS_OUTPUT_TYPE) { this.updateGrayBlocks(); } - pxt.tickEvent("blocks.create", { "block": blockId }, { interactiveConsent: true }); - if (ev.xml.tagName == 'SHADOW') + pxt.tickEvent("blocks.create", { "block": blockType }, { interactiveConsent: true }); + if (block?.isShadow()) this.cleanUpShadowBlocks(); if (!this.parent.state.tutorialOptions || !this.parent.state.tutorialOptions.metadata || !this.parent.state.tutorialOptions.metadata.flyoutOnly) this.parent.setState({ hideEditorFloats: false }); - workspace.fireEvent({ type: 'create', editor: 'blocks', blockId } as pxt.editor.CreateEvent); + workspace.fireEvent({ type: 'create', editor: 'blocks', blockId: blockType } as pxt.editor.CreateEvent); } else if (ev.type == Blockly.Events.VAR_CREATE || ev.type == Blockly.Events.VAR_RENAME || ev.type == Blockly.Events.VAR_DELETE) { // a new variable name is used or blocks were removed, // clear the toolbox caches as some blocks may need to be recomputed this.clearFlyoutCaches(); } - else if (ev.type == Blockly.Events.UI) { - if (ev.element == 'category') { - let toolboxVisible = !!ev.newValue; - if (toolboxVisible) pxt.setInteractiveConsent(true); - this.parent.setState({ hideEditorFloats: toolboxVisible }); - } + if (ev.type === Blockly.Events.TOOLBOX_ITEM_SELECT) { + let toolboxVisible = !!(ev as Blockly.Events.ToolboxItemSelect).newItem; + if (toolboxVisible) pxt.setInteractiveConsent(true); + this.parent.setState({ hideEditorFloats: toolboxVisible }); } else if (ev.type === pxtblockly.FIELD_EDITOR_OPEN_EVENT_TYPE) { const openEvent = ev as pxtblockly.FieldEditorOpenEvent; @@ -923,7 +867,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { }) - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) if (this.shouldShowCategories()) { this.renderToolbox(); } @@ -932,16 +875,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.initBlocklyToolbox(); this.initWorkspaceSounds(); initContextMenu(); - initCopyPaste(accessibleBlocksEnabled); - // This must come after initCopyPaste and initContextMenu. - // initCopyPaste overrides the default cut, copy, paste shortcuts. - // The keyboard navigation plugin utilizes these cut, copy and paste shortcuts - // and wraps them with additional behaviours (e.g., toast notifications). - // initContextMenu overrides the default context menu options. The plugin - // decorates the duplicate block context menu item to display the shortcut. - if (accessibleBlocksEnabled) { - this.initAccessibleBlocks(); - } + initCopyPaste(); + this.initKeyboardControls(); this.initWorkspaceSearch(); this.setupIntersectionObserver(); this.resize(); @@ -1016,23 +951,24 @@ export class Editor extends toolboxeditor.ToolboxEditor { } focusWorkspace() { - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) - if (accessibleBlocksEnabled) { - (this.editor.getSvgGroup() as SVGElement).focus(); - Blockly.hideChaff(); - - if (this.pendingKeyboardControlsHint) { - this.pendingKeyboardControlsHint = false; - this.showKeyboardControlsHint(); - } + (this.editor.getSvgGroup() as SVGElement).focus(); + Blockly.hideChaff(); + + if (this.pendingKeyboardControlsHint) { + this.pendingKeyboardControlsHint = false; + this.showKeyboardControlsHint(); } } showKeyboardControlsHint() { if (!this.editor || !Blockly.Msg["HELP_PROMPT"]) return; - const shortcut = pxt.BrowserUtils.isMac() ? "⌘ /" : lf("Ctrl") + " + /"; - const message = Blockly.Msg["HELP_PROMPT"].replace("%1", shortcut); - Blockly.Toast.show(this.editor, { message, id: "helpHint", oncePerSession: true }); + const shortcut = getShortcutKeysShort(LIST_SHORTCUTS_SHORTCUT); + if (!shortcut) return; + Blockly.Toast.show(this.editor, { + message: Blockly.Msg["HELP_PROMPT"].replace("%1", shortcut), + id: "helpHint", + oncePerSession: true, + }); } hasUndo() { @@ -1316,11 +1252,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { } public moveFocusToFlyout() { - if (this.keyboardNavigation) { - // It's the nested workspace focus tree that takes focus for navigation. - Blockly.FocusManager.getFocusManager().focusTree(this.editor.getFlyout().getWorkspace()) - this.setDefaultFlyoutCursorIfNeeded(this.editor.getFlyout()); - } + // It's the nested workspace focus tree that takes focus for navigation. + Blockly.FocusManager.getFocusManager().focusTree(this.editor.getFlyout().getWorkspace()) } // Modified from blockly-keyboard-experimentation plugin @@ -1342,33 +1275,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { return false; } - // Modified from blockly-keyboard-experimentation plugin - // https://github.com/google/blockly-keyboard-experimentation/blob/main/src/navigation.ts - getDefaultFlyoutCursorIfNeeded(flyout: Blockly.IFlyout): Blockly.IBoundedElement & Blockly.IFocusableNode | null { - const flyoutCursor = flyout.getWorkspace().getCursor(); - if (!flyoutCursor) { - return null; - } - const curNode = flyoutCursor.getCurNode(); - const sourceBlock = flyoutCursor.getSourceBlock(); - if (curNode && !this.isFlyoutItemDisposed(curNode, sourceBlock)) { - return null; - } - const flyoutContents = flyout.getContents(); - const defaultFlyoutItem = flyoutContents[0]; - if (!defaultFlyoutItem) return null; - return defaultFlyoutItem.getElement(); - } - - // Split from getDefaultFlyoutCursorIfNeeded in order to return the default flyout cursor separately - private setDefaultFlyoutCursorIfNeeded(flyout: Blockly.IFlyout): void { - const defaultFlyoutItemElement = this.getDefaultFlyoutCursorIfNeeded(flyout) - if (defaultFlyoutItemElement) { - const flyoutCursor = flyout.getWorkspace().getCursor(); - flyoutCursor.setCurNode(defaultFlyoutItemElement); - } - } - renderToolbox(immediate?: boolean) { if (pxt.shell.isReadOnly()) return; const blocklyToolboxDiv = this.getBlocklyToolboxDiv(); @@ -1519,11 +1425,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { } }); - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) - if (accessibleBlocksEnabled) { - KeyboardNavigation.registerKeyboardNavigationStyles(); - } - this.prepareBlockly(); }) .then(() => initEditorExtensionsAsync()) @@ -1853,7 +1754,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { const refreshBlockly = () => { this.delayLoadXml = this.getCurrentSource(); this.editor = undefined; - this.cleanupKeyboardNavigation(); this.prepareBlockly(hasCategories); this.domUpdate(); this.editor.scrollCenter(); @@ -1874,18 +1774,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { pxt.perf.measureEnd(Measurements.RefreshToolbox) } - cleanupKeyboardNavigation() { - if (this.keyboardNavigation) { - // This event doesn't always get cleaned up properly when a move is completed. - // Clear out any lingering registrations just in case. - // (This is already patched in blockly, but we need an update to get it) - this.unregisterBlocklyShortcutIfExists("commitMove"); - this.keyboardNavigation.dispose(); - this.keyboardNavigation = undefined; - initCopyPaste(false, true); // Re-initialize old copy/paste handlers - } - } - filterToolbox(showCategories?: boolean) { this.showCategories = showCategories; this.refreshToolbox(); @@ -2516,6 +2404,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { const copyWorkspace = this.editor; const copyCoords = copyWorkspace.id === data.workspaceId ? data.coord : undefined; + clearPasteHints(copyWorkspace); + // this pasting code is adapted from Blockly/core/shortcut_items.ts const doPaste = () => { const metricsManager = copyWorkspace.getMetricsManager(); @@ -2719,25 +2609,49 @@ function resolveLocalizedMarkdown(url: string) { return undefined; } +// Toast id constants match Blockly's internal hints.ts so the same toast slots +// are reused — paste dismissing the toast via clearPasteHints works either way. +const COPIED_HINT_ID = "copiedHint"; +const CUT_HINT_ID = "cutHint"; + +function showCopiedHint(workspace: Blockly.WorkspaceSvg) { + showPasteAvailableHint(workspace, Blockly.Msg["KEYBOARD_NAV_COPIED_HINT"], COPIED_HINT_ID); +} + +function showCutHint(workspace: Blockly.WorkspaceSvg) { + showPasteAvailableHint(workspace, Blockly.Msg["KEYBOARD_NAV_CUT_HINT"], CUT_HINT_ID); +} + +function showPasteAvailableHint(workspace: Blockly.WorkspaceSvg, template: string, id: string) { + if (!template) return; + const pasteKey = getShortcutKeysShort(Blockly.ShortcutItems.names.PASTE); + if (!pasteKey) return; + Blockly.Toast.show(workspace, { + message: template.replace("%1", pasteKey), + duration: 7, + id, + }); +} + +function clearPasteHints(workspace: Blockly.WorkspaceSvg) { + Blockly.Toast.hide(workspace, COPIED_HINT_ID); + Blockly.Toast.hide(workspace, CUT_HINT_ID); +} + // adapted from Blockly/core/shortcut_items.ts function copy(workspace: Blockly.WorkspaceSvg, e: Event, _shortcut: Blockly.ShortcutRegistry.KeyboardShortcut, scope: Blockly.ContextMenuRegistry.Scope) { // Prevent the default copy behavior, which may beep or otherwise indicate // an error due to the lack of a selection. e.preventDefault(); - workspace.hideChaff(); const focused = scope.focusedNode; + if (!focused || !Blockly.isCopyable(focused)) return false; - // If copying a block in the flyout via the context menu, the workspace is the flyout, - // otherwise (using the keyboard shortcut) the workspace is the main workspace. - // If the workspace is the main workspace, calling hideChaff will close the flyout, - // so focus the main workspace after that happens. - if ((focused as Blockly.BlockSvg).isInFlyout && !workspace.isFlyout) { - Blockly.getFocusManager().focusTree(workspace); + // Don't hideChaff when copying from the flyout, so the flyout stays open. + if (!focused.workspace.isFlyout) { + workspace.hideChaff(); } - if (!focused || !Blockly.isCopyable(focused)) return false; const copyData = focused.toCopyData(); - const copyWorkspace = focused.workspace instanceof Blockly.WorkspaceSvg ? focused.workspace @@ -2753,49 +2667,55 @@ function copy(workspace: Blockly.WorkspaceSvg, e: Event, _shortcut: Blockly.Shor copyWorkspace, pkg.mainEditorPkg().header.id ); + showCopiedHint(workspace); } return !!copyData; } // adapted from Blockly/core/shortcut_items.ts -function cut(workspace: Blockly.WorkspaceSvg, _e: Event, _shortcut: Blockly.ShortcutRegistry.KeyboardShortcut, scope: Blockly.ContextMenuRegistry.Scope) { +function cut(workspace: Blockly.WorkspaceSvg, e: Event, _shortcut: Blockly.ShortcutRegistry.KeyboardShortcut, scope: Blockly.ContextMenuRegistry.Scope) { const focused = scope.focusedNode; + let copied = false; if (focused instanceof Blockly.BlockSvg) { const copyData = focused.toCopyData(); - const copyWorkspace = workspace; - const copyCoords = focused.getRelativeToSurfaceXY(); saveCopyData( copyData, - copyCoords, - copyWorkspace, + focused.getRelativeToSurfaceXY(), + workspace, pkg.mainEditorPkg().header.id ); if (!shouldDuplicateOnDrag(focused)) { focused.checkAndDelete(); } - return true; + e.preventDefault(); + copied = true; } else if ( Blockly.isDeletable(focused) && focused.isDeletable() && Blockly.isCopyable(focused) ) { const copyData = focused.toCopyData(); - const copyWorkspace = workspace; const copyCoords = Blockly.isDraggable(focused) ? focused.getRelativeToSurfaceXY() : null; saveCopyData( copyData, copyCoords, - copyWorkspace, + workspace, pkg.mainEditorPkg().header.id ); focused.dispose(); - return true; + workspace.getAudioManager().play("delete"); + e.preventDefault(); + copied = true; } - return false; + + if (copied) { + showCutHint(workspace); + } + return copied; } function saveCopyData( @@ -2833,8 +2753,8 @@ function copyDataKey() { } function maybeCloneBlockForMove(workspace: Blockly.WorkspaceSvg) { - const block = workspace?.getCursor()?.getSourceBlock(); - + const block = Blockly.getFocusManager().getFocusedNode(); + if (!(block instanceof Blockly.BlockSvg)) return; if (block && shouldDuplicateOnDrag(block)) { Blockly.Events.setGroup(true); const xml = Blockly.Xml.blockToDom(block); @@ -2862,4 +2782,4 @@ function getBlockConfigXml(block: toolbox.BlockDefinition, blockConfig: pxt.tuto return xml; } return undefined; -} \ No newline at end of file +} diff --git a/webapp/src/components/KeyboardControlsHelp.tsx b/webapp/src/components/KeyboardControlsHelp.tsx index 6306f18be744..3ace985bdd2d 100644 --- a/webapp/src/components/KeyboardControlsHelp.tsx +++ b/webapp/src/components/KeyboardControlsHelp.tsx @@ -1,6 +1,8 @@ import * as React from "react"; -import { getActionShortcut, getActionShortcutsAsKeys, ShortcutNames } from "../shortcut_formatting"; +import * as Blockly from "blockly"; +import { getShortcutKeysShortAll, LIST_SHORTCUTS_SHORTCUT } from "../shortcut_formatting"; +const names = Blockly.ShortcutItems.names; const isMacPlatform = pxt.BrowserUtils.isMac(); const KeyboardControlsHelp = () => { @@ -8,29 +10,28 @@ const KeyboardControlsHelp = () => { React.useEffect(() => { ref.current?.focus() }, []); - const ctrl = lf("Ctrl"); + const ctrl = lf("{id:keyboard symbol}Ctrl"); const cmd = isMacPlatform ? "⌘" : ctrl; - const optionOrCtrl = isMacPlatform ? "⌥" : ctrl; - const contextMenuRow = - const cleanUpRow = + const contextMenuRow = + const cleanUpRow = const orAsJoiner = lf("or") - const enterOrSpace = { shortcuts: getActionShortcutsAsKeys(ShortcutNames.EDIT_OR_CONFIRM), joiner: orAsJoiner} + const enterOrSpace = { shortcuts: getShortcutKeysShortAll(names.PERFORM_ACTION), joiner: orAsJoiner} const editOrConfirmRow = return ( @@ -108,8 +109,8 @@ const Row = ({ name, shortcuts = [], joiner, children}: RowProps) => { const shortcutElements = shortcuts.map((s, idx) => { if (typeof s === "string") { // Pull keys from shortcut registry. - const shortcut = getActionShortcut(s); - return shortcut === null ? null : + const variants = getShortcutKeysShortAll(s); + return variants.length === 0 ? null : } else { // Display keys as specified. return @@ -177,4 +178,4 @@ const Key = ({ value }: { value: string }) => { return {value} } -export default KeyboardControlsHelp; \ No newline at end of file +export default KeyboardControlsHelp; diff --git a/webapp/src/components/soundEffectEditor/SoundEffectEditor.tsx b/webapp/src/components/soundEffectEditor/SoundEffectEditor.tsx index c302959ce95a..ca93d838a893 100644 --- a/webapp/src/components/soundEffectEditor/SoundEffectEditor.tsx +++ b/webapp/src/components/soundEffectEditor/SoundEffectEditor.tsx @@ -107,6 +107,8 @@ export const SoundEffectEditor = (props: SoundEffectEditorProps) => { if (selectedView === "gallery") return; play(); + ev.preventDefault(); + ev.stopPropagation(); }, [play, selectedView]) React.useEffect(() => { @@ -305,4 +307,4 @@ function percentChance(percent: number) { function clamp(value: number, min: number, max: number) { return Math.floor(Math.min(max, Math.max(min, value))) -} \ No newline at end of file +} diff --git a/webapp/src/container.tsx b/webapp/src/container.tsx index 7299de1d8d01..04c940f59707 100644 --- a/webapp/src/container.tsx +++ b/webapp/src/container.tsx @@ -99,11 +99,10 @@ export class DocsMenu extends data.PureComponent { renderCore() { const { parent, editor } = this.props; const targetTheme = pxt.appTarget.appTheme; - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS); const items: MenuItem[] = []; - if (this.props.inBlocks && accessibleBlocksEnabled) { + if (this.props.inBlocks) { items.push({ role: "menuitem", label: lf("Keyboard Controls"), @@ -145,7 +144,6 @@ export class DocsMenu extends data.PureComponent { export interface SettingsMenuProps extends ISettingsProps { greenScreen: boolean; - accessibleBlocks: boolean; showShare?: boolean; inBlocks: boolean; inTutorial: boolean; @@ -154,7 +152,6 @@ export interface SettingsMenuProps extends ISettingsProps { // This Component overrides shouldComponentUpdate, be sure to update that if the state is updated export interface SettingsMenuState { greenScreen?: boolean; - accessibleBlocks?: boolean; showShare?: boolean; } @@ -175,7 +172,6 @@ export class SettingsMenu extends data.Component(auth.ACCESSIBLE_BLOCKS); const targetTheme = pxt.appTarget.appTheme; const packages = pxt.appTarget.cloud && !!pxt.appTarget.cloud.packages; const reportAbuse = pxt.appTarget.cloud && pxt.appTarget.cloud.sharing && pxt.appTarget.cloud.importing; @@ -519,15 +506,6 @@ export class SettingsMenu extends data.Component { {pxt.appTarget.appTheme.useUploadMessage ? lf("Upload") : lf("Download")}
; } -} \ No newline at end of file +} diff --git a/webapp/src/core.ts b/webapp/src/core.ts index b542097a2895..cd12dbaebe30 100644 --- a/webapp/src/core.ts +++ b/webapp/src/core.ts @@ -343,21 +343,6 @@ export function getHighContrastOnce(): boolean { return ThemeManager.isCurrentThemeHighContrast(); } -/** - * Returns true if keyboard controls should be enabled by default. - */ -export function isKeyboardControlsByDefault(): boolean { - return /keyboardcontrols=1/i.test(window.location.href) || pxt.appTarget.appTheme?.blocklyKeyboardControlsByDefault; -} - -export async function toggleAccessibleBlocks(eventSource: string) { - await setAccessibleBlocks(!data.getData(auth.ACCESSIBLE_BLOCKS), eventSource); -} - -export async function setAccessibleBlocks(on: boolean, eventSource: string) { - await auth.setAccessibleBlocksPrefAsync(on, eventSource); -} - export async function setLanguage(lang: string) { pxt.BrowserUtils.setCookieLang(lang); pxt.Util.setUserLanguage(lang); diff --git a/webapp/src/headerbar.tsx b/webapp/src/headerbar.tsx index f3abcae74b40..6b15dfe02998 100644 --- a/webapp/src/headerbar.tsx +++ b/webapp/src/headerbar.tsx @@ -252,7 +252,7 @@ export class HeaderBar extends data.Component { // TODO: eventually unify these components into one menu getSettingsMenu = (view: HeaderBarView) => { - const { greenScreen, accessibleBlocks, header } = this.props.parent.state; + const { greenScreen, header } = this.props.parent.state; switch (view){ case "home": return @@ -262,7 +262,6 @@ export class HeaderBar extends data.Component { { } -} \ No newline at end of file +} diff --git a/webapp/src/projects.tsx b/webapp/src/projects.tsx index 44ffa9401cec..3cd201e65aca 100644 --- a/webapp/src/projects.tsx +++ b/webapp/src/projects.tsx @@ -639,10 +639,6 @@ export class ProjectSettingsMenu extends data.Component = { + 'Control': Blockly.Msg['CONTROL_KEY'], + 'Meta': Blockly.Msg['COMMAND_KEY'], + 'Alt': isMacPlatform ? Blockly.Msg['OPTION_KEY'] : Blockly.Msg['ALT_KEY'], +}; + +const shortModifierNames: Record = { + 'Control': Blockly.Msg['CONTROL_KEY'], + 'Meta': '⌘', + 'Alt': isMacPlatform ? '⌥' : Blockly.Msg['ALT_KEY'], +}; + +/** + * Mirror of Blockly's getKeyName. Translatable key labels are read from + * Blockly.Msg (populated by initAccessibilityMessages). + */ +function getKeyName(keyCode: number): string { + if (keyCode >= 65 && keyCode <= 90) { + // letters a-z + return String.fromCharCode(keyCode); + } + + const keyNames: Record = { + 8: Blockly.Msg['BACKSPACE_KEY'], + 9: Blockly.Msg['TAB_KEY'], + 13: Blockly.Msg['ENTER_KEY'], + 16: Blockly.Msg['SHIFT_KEY'], + 17: Blockly.Msg['CONTROL_KEY'], + 18: Blockly.Msg['ALT_KEY'], + 19: Blockly.Msg['PAUSE_KEY'], + 20: Blockly.Msg['CAPS_LOCK_KEY'], + 27: Blockly.Msg['ESCAPE'], + 32: Blockly.Msg['SPACE_KEY'], + 33: Blockly.Msg['PAGE_UP_KEY'], + 34: Blockly.Msg['PAGE_DOWN_KEY'], + 35: Blockly.Msg['END_KEY'], + 36: Blockly.Msg['HOME_KEY'], + 37: '←', + 38: '↑', + 39: '→', + 40: '↓', + 45: Blockly.Msg['INSERT_KEY'], + 46: Blockly.Msg['DELETE_KEY'], + 48: '0', + 49: '1', + 50: '2', + 51: '3', + 52: '4', + 53: '5', + 54: '6', + 55: '7', + 56: '8', + 57: '9', + 59: ';', + 61: '=', + 93: Blockly.Msg['CONTEXT_MENU_KEY'], + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9', + 106: '×', + 107: '+', + 109: '−', + 110: '.', + 111: '÷', + 112: 'F1', + 113: 'F2', + 114: 'F3', + 115: 'F4', + 116: 'F5', + 117: 'F6', + 118: 'F7', + 119: 'F8', + 120: 'F9', + 121: 'F10', + 122: 'F11', + 123: 'F12', + 186: ';', + 187: '=', + 189: '-', + 188: ',', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: "'", + 224: '⌘', + }; + + const keyName = keyNames[keyCode]; + if (keyName) return keyName; + pxt.warn('Unknown key code: ' + keyCode); + return String.fromCharCode(keyCode); } /** * Find the relevant shortcuts for the given action for the current platform. * Keys are returned in a user facing format. * - * This could be considerably simpler if we only bound shortcuts relevant to the - * current platform or tagged them with a platform. - * - * Copied from blockly-keyboard-experiment. - * See https://github.com/google/blockly-keyboard-experimentation/blob/main/src/shortcut_formatting.ts + * Mirrors Blockly's internal getShortcutKeys (core/utils/shortcut_formatting.ts). + * Kept as a local copy because Blockly doesn't expose this on its public API + * surface; the algorithm should track Blockly's version. * - * @param action The action name, e.g. "cut". + * @param shortcutName The action name, e.g. "cut". * @param modifierNames The names to use for the Meta/Control/Alt modifiers. * @returns The formatted shortcuts. */ -export function getActionShortcutsAsKeys( - action: string, +function getShortcutKeys( + shortcutName: string, + modifierNames: Record, ): string[][] { - const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action); + const shortcuts = Blockly.ShortcutRegistry.registry.getKeyCodesByShortcutName(shortcutName); if (shortcuts.length === 0) { return []; } @@ -62,11 +167,7 @@ export function getActionShortcutsAsKeys( 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; - })) + shortcutsAsParts.forEach(s => s.sort((a, b) => modifierOrder(a) - modifierOrder(b))); // Needed to prefer Command to Option where we've bound Alt. shortcutsAsParts.sort((a, b) => { @@ -81,9 +182,7 @@ export function getActionShortcutsAsKeys( currentPlatform = currentPlatform.length === 0 ? shortcutsAsParts : currentPlatform; // Prefer simpler shortcuts. This promotes Ctrl+Y for redo. - currentPlatform.sort((a, b) => { - return a.length - b.length; - }); + currentPlatform.sort((a, b) => 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. @@ -93,84 +192,28 @@ export function getActionShortcutsAsKeys( ), ); const chosen = hasModifiers ? [currentPlatform[0]] : currentPlatform; - return chosen.map((shortcut) => { - return shortcut - .map((numericOrModifier) => shortcutRegistryKeyNames[numericOrModifier] ?? numericOrModifier) - }); + return chosen.map((shortcut) => + shortcut + .map((maybeNumeric) => + Number.isFinite(+maybeNumeric) + ? getKeyName(+maybeNumeric) + : maybeNumeric, + ) + .map((k) => upperCaseFirst(modifierNames[k] ?? k)), + ); } -/** - * Localized key names for common characters. - * Keys are the ones used in ShortcutRegistry.createSerializedKey. - * E.g. ['Shift+Control+90', 'Shift+Alt+90', 'Shift+Meta+90', 'Control+89'] - */ -const shortcutRegistryKeyNames: Record = { - // Numeric (subset we use). - 8: lf("Backspace"), - 13: lf("Enter"), - 27: lf("Esc"), - 32: lf("Space"), - 37: '←', - 38: '↑', - 39: '→', - 40: '↓', - 46: lf("Delete"), - 48: '0', - 49: '1', - 50: '2', - 51: '3', - 52: '4', - 53: '5', - 54: '6', - 55: '7', - 56: '8', - 57: '9', - 65: 'A', - 66: 'B', - 67: 'C', - 68: 'D', - 69: 'E', - 70: 'F', - 71: 'G', - 72: 'H', - 73: 'I', - 74: 'J', - 75: 'K', - 76: 'L', - 77: 'M', - 78: 'N', - 79: 'O', - 80: 'P', - 81: 'Q', - 82: 'R', - 83: 'S', - 84: 'T', - 85: 'U', - 86: 'V', - 87: 'W', - 88: 'X', - 89: 'Y', - 90: 'Z', - 191: '/', - // Modifiers - 'Shift': lf("Shift"), - 'Control': lf("Ctrl"), - 'Meta': '⌘', - 'Alt': isMacPlatform ? '⌥' : lf("Alt"), -}; +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' -]; +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; - } \ No newline at end of file + const order = modifierOrdering.indexOf(key); + // Regular keys at the end. + return order === -1 ? Number.MAX_VALUE : order; +} diff --git a/webapp/src/toolbox.tsx b/webapp/src/toolbox.tsx index 1e33630031d1..3343a1ca889f 100644 --- a/webapp/src/toolbox.tsx +++ b/webapp/src/toolbox.tsx @@ -1,5 +1,6 @@ /// +import * as Blockly from "blockly" import * as React from "react" import * as data from "./data" import * as editor from "./toolboxeditor" @@ -177,24 +178,33 @@ export class Toolbox extends data.Component { this.selectedItem = item; } - setPreviousItem() { + /** @returns true if focus moved, false if we were already at the boundary. */ + setPreviousItem(): boolean { if (this.selectedIndex > 0) { const newIndex = --this.selectedIndex; // Check if the previous item has a subcategory let previousItem = this.items[newIndex]; this.setSelection(previousItem, newIndex); + return true; } else if (this.state.showSearchBox) { // Focus the search box if it exists const searchBox = this.refs.searchbox as ToolboxSearch; - if (searchBox) searchBox.focus(); + if (searchBox) { + searchBox.focus(); + return true; + } } + return false; } - setNextItem() { + /** @returns true if focus moved, false if we were already at the boundary. */ + setNextItem(): boolean { if (this.items.length - 1 > this.selectedIndex) { const newIndex = ++this.selectedIndex; this.setSelection(this.items[newIndex], newIndex); + return true; } + return false; } setSearch() { @@ -522,33 +532,42 @@ export class Toolbox extends data.Component { } handleKeyDown(e: React.KeyboardEvent) { + // Take care to avoid default scroll behaviors and Blockly shortcuts running that overlap. const isRtl = Util.isUserLanguageRtl(); + const audioManager = (Blockly.getMainWorkspace() as Blockly.WorkspaceSvg)?.getAudioManager(); const charCode = core.keyCodeFromEvent(e); if (charCode == 40 /* Down arrow key */) { + let moved = false; if (this.state.selectedItem) { - this.nextItem(); + moved = this.nextItem(); } else { this.selectFirstItem(); + moved = true; } - // Don't trigger scroll behaviour inside the toolbox. e.preventDefault(); + e.stopPropagation(); + Blockly.keyboardNavigationController.setIsActive(true); + if (!moved) audioManager?.playErrorBeep(); } else if (charCode == 38 /* Up arrow key */) { + let moved = false; if (this.state.selectedItem) { - this.previousItem(); + moved = this.previousItem(); } - // Don't trigger scroll behaviour inside the toolbox. e.preventDefault(); + e.stopPropagation(); + Blockly.keyboardNavigationController.setIsActive(true); + if (!moved) audioManager?.playErrorBeep(); } else if ((charCode == 39 /* Right arrow key */ && !isRtl) || (charCode == 37 /* Left arrow key */ && isRtl)) { if (this.selectedTreeRow.nameid !== "addpackage") { + Blockly.keyboardNavigationController.setIsActive(true); // Focus inside flyout this.moveFocusToFlyout(); - } else { - // Prevent Blockly focus changes for the addpackage category item. e.preventDefault(); e.stopPropagation(); } + // addpackage has no flyout — fall through and let Blockly beep. } else if (charCode == 27) { // ESCAPE // Close the flyout this.closeFlyout(); @@ -559,6 +578,7 @@ export class Toolbox extends data.Component { onCategoryClick(treeRow, index, false); e.preventDefault(); e.stopPropagation(); + Blockly.keyboardNavigationController.setIsActive(true); } } else if (charCode == core.TAB_KEY || charCode == 37 /* Left arrow key */ @@ -570,21 +590,23 @@ export class Toolbox extends data.Component { // Escape tab and shift key } else { this.setSearch(); + // We don't want any Blockly shortcut to fight search. + e.stopPropagation(); } } - previousItem() { + previousItem(): boolean { const editorname = this.props.editorname; pxt.tickEvent(`${editorname}.toolbox.keyboard.prev`, undefined, { interactiveConsent: true }); - this.setPreviousItem(); + return this.setPreviousItem(); } - nextItem() { + nextItem(): boolean { const editorname = this.props.editorname; pxt.tickEvent(`${editorname}.toolbox.keyboard.next`, undefined, { interactiveConsent: true }); - this.setNextItem(); + return this.setNextItem(); } renderCore() { @@ -1147,6 +1169,7 @@ export class ToolboxSearch extends data.Component