From 56d21ff50bd72158f70361a9e07bc1d98681eff4 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 19 May 2026 17:23:37 +0100 Subject: [PATCH 1/8] Upgrade Blockly to v13.0.0-beta.5 Bump from 12.3.1 and drop the @blockly/keyboard-navigation plugin (now built into Blockly core). User-facing changes: - Keyboard navigation and screen-reader support are always enabled. Removed the accessible-blocks preference and its toggle UI. The heavier focus outlines for toolbox/workspace are enabled when using a meaningful keyboard navigation (previously matched setting). - Aligned pxt's cut/copy/paste with copy/cut toasts and the cut audio cue. - Variable creation focuses the new variable in the flyout (v13 behaviour); manual focus restore to the button removed. - Audio cues when keyboard navigation reaches limits and when a block is dropped on the workspace from move mode or drag. - Context menu changes were intentionally not ported from the keyboard navigation plugin. We could consider adding Cut alongside Copy/Paste, Copy/Paste are also not Blockly default. - Mac uses Cmd not Option as the unconstrained movement modifier. - Left and right arrow keys no longer wrap based on screen reader user feedback - Added an "end" position at the end of statement inputs. This can be useful for block insertion but is mainly motivated by helping screen reader users understand hierarchy in a way that's closer to text-based coding and more usable than a full tree structure. API: - Custom drag strategy collapsed from a substantial copy of Blockly's internals thin subclass due as the base is now exported. - Event handler shapes, navigator/focus manager flows, bubble interfaces and widget-div positioning all updated to match v13. - Shortcut formatting is now a tracked port of Blockly's internal helper (not on the public API). Workarounds for regressions in pxt customisations: - Preserve duplicate-shadow-on-drag (e.g. melody). Patched at the gesture and drag-strategy level. Previously this only worked because of an odd behaviour in Blockly where the shadow was briefly focused (you can actually see this in live MakeCode when clicking a block). - Snap dragged blocks back to the pointer at the snap-radius boundary (see https://github.com/RaspberryPiFoundation/blockly/issues/9898). - Re-stack the connection-preview indicator over the highlight path. CSS: - Blockly v13 injects its CSS via adoptedStyleSheets, flipping the cascade. We've generally added body to selectors to address this. - The cascade change is currently under investigation to see if we can do better here in Blockly. - Related, adoptedStyleSheets requires Safari 16, last year we agreed 15, Blockly are up for a fix that's backwards compatible (will help with some iPads that still get 15.x security fixes) and micro:bit are taking a look here. Translation: - New aria labels, move-mode announcements, and screen-reader-mode toggle inherited from Blockly v13. Known regressions vs Blockly 12 + keyboard nav plugin: - Glitch when dragging affecting mouse users - https://github.com/RaspberryPiFoundation/blockly/issues/9898 (Should be easy fix) - Initial block placement isn't intelligent - https://github.com/RaspberryPiFoundation/blockly/issues/9878 (Blockly plan to fix) - Focused inputs not always respected for keyboard block insert - https://github.com/RaspberryPiFoundation/blockly/issues/9899 (Newly raised) Follow ups will be needed for better screen reader integration and to update the keyboard controls help. Closes https://github.com/microsoft/pxt-microbit/issues/6870 --- localtypings/pxtarget.d.ts | 1 - localtypings/pxteditor.d.ts | 3 - package.json | 9 +- pxtblocks/builtins/variables.ts | 4 - pxtblocks/contextMenu/blockItems.ts | 3 +- pxtblocks/contextMenu/workspaceItems.ts | 4 +- pxtblocks/copyPaste.ts | 74 +-- pxtblocks/fields/field_asset.ts | 7 +- pxtblocks/fields/field_ledmatrix.ts | 7 +- pxtblocks/fields/field_sound_effect.ts | 7 +- pxtblocks/fields/field_utils.ts | 14 +- pxtblocks/loader.ts | 135 ++++- pxtblocks/monkeyPatches/gesture.ts | 23 +- pxtblocks/monkeyPatches/index.ts | 5 +- pxtblocks/plugins/comments/bubble.ts | 37 +- .../plugins/duplicateOnDrag/dragStrategy.ts | 503 ++---------------- pxtblocks/plugins/flyout/blockInflater.ts | 2 + .../plugins/flyout/buttonFlyoutInflater.ts | 7 +- .../plugins/newVariableField/fieldVariable.ts | 2 +- .../plugins/renderer/connectionPreviewer.ts | 7 +- pxtblocks/plugins/renderer/constants.ts | 6 +- pxteditor/editorcontroller.ts | 6 +- pxtlib/auth.ts | 2 - theme/blockly-core.less | 36 +- theme/melodyeditor.less | 4 +- webapp/src/accessibility.tsx | 35 +- webapp/src/app.tsx | 40 +- webapp/src/auth.ts | 35 -- webapp/src/blocks.tsx | 362 +++++-------- .../src/components/KeyboardControlsHelp.tsx | 12 +- .../soundEffectEditor/SoundEffectEditor.tsx | 4 +- webapp/src/container.tsx | 26 +- webapp/src/core.ts | 15 - webapp/src/headerbar.tsx | 5 +- webapp/src/projects.tsx | 4 - webapp/src/shortcut_formatting.ts | 251 +++++---- webapp/src/toolbox.tsx | 49 +- 37 files changed, 655 insertions(+), 1091 deletions(-) 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..bfc9194c17fb 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, + // Matches ShortcutNames.CLEAN_UP — re-registered with key F in blocks.tsx. + associatedKeyboardShortcut: 'clean_up_workspace', }; 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..64339993e50a 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("Ctrl"), + COMMAND_KEY: lf("⌘ Command"), + OPTION_KEY: lf("⌥ Option"), + ALT_KEY: lf("Alt"), + ENTER_KEY: lf("Enter"), + BACKSPACE_KEY: lf("Backspace"), + DELETE_KEY: lf("Delete"), + ESCAPE: lf("Esc"), + TAB_KEY: lf("Tab"), + SHIFT_KEY: lf("Shift"), + CAPS_LOCK_KEY: lf("Caps Lock"), + SPACE_KEY: lf("Space"), + PAGE_UP_KEY: lf("Page Up"), + PAGE_DOWN_KEY: lf("Page Down"), + END_KEY: lf("End"), + HOME_KEY: lf("Home"), + INSERT_KEY: lf("Insert"), + PAUSE_KEY: lf("Pause"), + CONTEXT_MENU_KEY: lf("≣ Menu"), + UNNAMED_KEY: lf("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("disabled"), + BLOCK_LABEL_COLLAPSED: lf("collapsed"), + BLOCK_LABEL_REPLACEABLE: lf("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("command"), + BLOCK_LABEL_CONTAINER: lf("container"), + BLOCK_LABEL_VALUE: lf("value"), + BLOCK_LABEL_STACK_BLOCKS: lf("%1 stack blocks"), + // Aria labels for inputs. + INPUT_LABEL_INDEX: lf("input %1"), + INPUT_LABEL_VALUE: lf("value position"), + INPUT_LABEL_STATEMENT: lf("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("input"), + ARIA_TYPE_FIELD_NUMBER: lf("number"), + ARIA_TYPE_FIELD_TEXT_INPUT: lf("text"), + // Field state labels. + FIELD_LABEL_CHECKBOX_CHECKED: lf("Checked"), + FIELD_LABEL_CHECKBOX_UNCHECKED: lf("Not checked"), + FIELD_LABEL_EDIT_PREFIX: lf("Edit %1"), + FIELD_LABEL_EMPTY: lf("empty"), + FIELD_LABEL_OPTION_INDEX: lf("Option %1"), + FIELD_LABEL_VARIABLE: lf("Variable '%1'"), + // Bubble labels. + BUBBLE_LABEL_COMMENT: lf("Comment: %1"), + BUBBLE_LABEL_DEFAULT: lf("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..94a927906963 100644 --- a/pxtblocks/monkeyPatches/gesture.ts +++ b/pxtblocks/monkeyPatches/gesture.ts @@ -1,10 +1,31 @@ import * as Blockly from "blockly"; +import { isAllowlistedShadow } from "../plugins/duplicateOnDrag/duplicateOnDrag"; 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; + 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 +132,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/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..8dc72d428a32 100644 --- a/pxtblocks/plugins/renderer/connectionPreviewer.ts +++ b/pxtblocks/plugins/renderer/connectionPreviewer.ts @@ -38,6 +38,11 @@ export class ConnectionPreviewer extends Blockly.InsertionMarkerPreviewer { this.staticConnectionIndicator = this.createConnectionIndicator(staticIndicatorParent, staticConn); } this.staticConnectionIndicator.parentElement.appendChild(this.staticConnectionIndicator); + // RenderedConnection.highlight() re-appends the connection's highlight + // path to the end of svgRoot every call, painting it over the indicator + // group. Re-append the group to the end of svgRoot to keep the dot on top. + const staticIndicatorParent = this.staticConnectionIndicator.parentElement; + staticIndicatorParent.parentElement?.appendChild(staticIndicatorParent); const radius = ConnectionPreviewer.CONNECTION_INDICATOR_RADIUS; const offset = draggedConn.getOffsetInBlock(); @@ -95,4 +100,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..179ce6fb6d10 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; } - } /******************************* @@ -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 8ba424a9182c..b66552214811 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; @@ -264,7 +264,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..584c46689041 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; @@ -40,7 +39,7 @@ import { flow, initCopyPaste } from "../../pxtblocks"; import { initContextMenu } from "../../pxtblocks/contextMenu"; 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, ShortcutNames } from "./shortcut_formatting"; interface CopyDataEntry { version: 1; @@ -77,8 +76,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 +157,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; @@ -548,6 +535,17 @@ export class Editor extends toolboxeditor.ToolboxEditor { * it to select the first category and clear the selection. */ const that = this; + 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; + } Blockly.Toolbox.prototype.getFocusableElement = function() { return that.getToolboxDiv()?.querySelector(".blocklyTreeRoot [role=tree]") as HTMLElement ?? that.getBlocksAreaDiv(); }; @@ -591,13 +589,8 @@ 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; } @@ -641,99 +634,46 @@ export class Editor extends toolboxeditor.ToolboxEditor { }; } - 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: ShortcutNames.LIST_SHORTCUTS, + 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: ShortcutNames.CLEAN_UP, + // 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 +756,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 +782,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 +790,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 +853,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { }) - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) if (this.shouldShowCategories()) { this.renderToolbox(); } @@ -932,16 +861,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 +937,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(ShortcutNames.LIST_SHORTCUTS); + if (!shortcut) return; + Blockly.Toast.show(this.editor, { + message: Blockly.Msg["HELP_PROMPT"].replace("%1", shortcut), + id: "helpHint", + oncePerSession: true, + }); } hasUndo() { @@ -1316,10 +1238,9 @@ export class Editor extends toolboxeditor.ToolboxEditor { } public moveFocusToFlyout() { - if (this.keyboardNavigation) { + if (Blockly.keyboardNavigationController.getIsActive()) { // 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()); } } @@ -1342,33 +1263,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 +1413,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { } }); - const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) - if (accessibleBlocksEnabled) { - KeyboardNavigation.registerKeyboardNavigationStyles(); - } - this.prepareBlockly(); }) .then(() => initEditorExtensionsAsync()) @@ -1853,7 +1742,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 +1762,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 +2392,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 +2597,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 +2655,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 +2741,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 +2770,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..cf9ac09f8254 100644 --- a/webapp/src/components/KeyboardControlsHelp.tsx +++ b/webapp/src/components/KeyboardControlsHelp.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { getActionShortcut, getActionShortcutsAsKeys, ShortcutNames } from "../shortcut_formatting"; +import { getShortcutKeysShortAll, ShortcutNames } from "../shortcut_formatting"; const isMacPlatform = pxt.BrowserUtils.isMac(); @@ -14,7 +14,7 @@ const KeyboardControlsHelp = () => { const contextMenuRow = const cleanUpRow = const orAsJoiner = lf("or") - const enterOrSpace = { shortcuts: getActionShortcutsAsKeys(ShortcutNames.EDIT_OR_CONFIRM), joiner: orAsJoiner} + const enterOrSpace = { shortcuts: getShortcutKeysShortAll(ShortcutNames.EDIT_OR_CONFIRM), joiner: orAsJoiner} const editOrConfirmRow = return (
; } -} \ 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 e48253162eb6..bace6f7d0a04 100644 --- a/webapp/src/projects.tsx +++ b/webapp/src/projects.tsx @@ -618,10 +618,6 @@ export class ProjectSettingsMenu extends data.Component = { + 'Control': lf("Ctrl"), + 'Meta': lf("Command"), + 'Alt': isMacPlatform ? lf("Option") : lf("Alt"), +}; + +const shortModifierNames: Record = { + 'Control': lf("Ctrl"), + 'Meta': '⌘', + 'Alt': isMacPlatform ? '⌥' : lf("Alt"), +}; + +/** + * User-facing name for a keycode. Mirrors Blockly's getKeyName but uses pxt's + * translation function for translatable strings. + */ +function getKeyName(keyCode: number): string { + if (keyCode >= 65 && keyCode <= 90) { + // letters a-z + return String.fromCharCode(keyCode); + } + + const keyNames: Record = { + 8: lf("Backspace"), + 9: lf("Tab"), + 13: lf("Enter"), + 16: lf("Shift"), + 17: lf("Ctrl"), + 18: lf("Alt"), + 19: lf("Pause"), + 20: lf("Caps Lock"), + 27: lf("Esc"), + 32: lf("Space"), + 33: lf("Page Up"), + 34: lf("Page Down"), + 35: lf("End"), + 36: lf("Home"), + 37: '←', + 38: '↑', + 39: '→', + 40: '↓', + 45: lf("Insert"), + 46: lf("Delete"), + 48: '0', + 49: '1', + 50: '2', + 51: '3', + 52: '4', + 53: '5', + 54: '6', + 55: '7', + 56: '8', + 57: '9', + 59: ';', + 61: '=', + 93: lf("Context Menu"), + 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 = ShortcutRegistry.registry.getKeyCodesByShortcutName(shortcutName); if (shortcuts.length === 0) { return []; } @@ -62,11 +191,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 +206,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 +216,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..e67ab306daf8 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") { // Focus inside flyout this.moveFocusToFlyout(); - } else { - // Prevent Blockly focus changes for the addpackage category item. e.preventDefault(); e.stopPropagation(); + Blockly.keyboardNavigationController.setIsActive(true); } + // 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 Date: Wed, 20 May 2026 11:28:49 +0100 Subject: [PATCH 2/8] Restore dragging-block translucency lost in Blockly v13 cascade flip Blockly v13 injects its CSS via adoptedStyleSheets, applied after the document stylesheets per spec, so its .blocklyDragging>.blocklyPath { fill-opacity: .8 } now wins the same-specificity tie against PXT's { fill-opacity: 0.7 } where in v12 source order favoured PXT. Bump PXT's selector specificity with a body prefix, matching the pattern used for the other v13-adopted-stylesheet overrides. --- theme/blockly-core.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme/blockly-core.less b/theme/blockly-core.less index 179ce6fb6d10..c0ed47eb29cb 100644 --- a/theme/blockly-core.less +++ b/theme/blockly-core.less @@ -279,7 +279,7 @@ body .pxt-renderer.classic-theme { /* Transulcent blocks when dragging */ -.blocklyDragging>.blocklyPath { +body .blocklyDragging>.blocklyPath { fill-opacity: 0.7; } From cc87b5147c5e474fc5f58f55ac7d4a3e14dcdfba Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 20 May 2026 11:28:49 +0100 Subject: [PATCH 3/8] Make connection preview correct for keyboard moves / block resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the keyboard-mode line hide so the connection line shows during keyboard moves as well as mouse drags. Refresh the preview after the in-flight render queue drains rather than on the next animation frame. base.startDrag nudges the block by BLOCK_CONNECTION_OFFSET after firing the preview, and disconnecting from a parent can queue a static-block re-render that shifts the connection offsets — both leave the synchronous preview pass stale. Re-apply the indicator translate() transforms in the refresh pass so the dots track the post-render connection offsets rather than the pre-render positions baked in at creation time. --- .../plugins/renderer/connectionPreviewer.ts | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/pxtblocks/plugins/renderer/connectionPreviewer.ts b/pxtblocks/plugins/renderer/connectionPreviewer.ts index 8dc72d428a32..dc3c259815ae 100644 --- a/pxtblocks/plugins/renderer/connectionPreviewer.ts +++ b/pxtblocks/plugins/renderer/connectionPreviewer.ts @@ -37,43 +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); - // RenderedConnection.highlight() re-appends the connection's highlight - // path to the end of svgRoot every call, painting it over the indicator - // group. Re-append the group to the end of svgRoot to keep the dot on top. - const staticIndicatorParent = this.staticConnectionIndicator.parentElement; - staticIndicatorParent.parentElement?.appendChild(staticIndicatorParent); + 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 { From 437fb6e894b07bd8311ef620c4b38eeadbcdbb93 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 20 May 2026 14:45:11 +0100 Subject: [PATCH 4/8] Fix click then right arrow on the toolbox This had a weird first time behaviour where it just called setActive so didn't move to the flyout. --- webapp/src/blocks.tsx | 6 ++---- webapp/src/toolbox.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index 584c46689041..c963057c4d41 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -1238,10 +1238,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { } public moveFocusToFlyout() { - if (Blockly.keyboardNavigationController.getIsActive()) { - // It's the nested workspace focus tree that takes focus for navigation. - Blockly.FocusManager.getFocusManager().focusTree(this.editor.getFlyout().getWorkspace()) - } + // 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 diff --git a/webapp/src/toolbox.tsx b/webapp/src/toolbox.tsx index e67ab306daf8..3343a1ca889f 100644 --- a/webapp/src/toolbox.tsx +++ b/webapp/src/toolbox.tsx @@ -561,11 +561,11 @@ export class Toolbox extends data.Component { } 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(); e.preventDefault(); e.stopPropagation(); - Blockly.keyboardNavigationController.setIsActive(true); } // addpackage has no flyout — fall through and let Blockly beep. } else if (charCode == 27) { // ESCAPE From 75b37e11b7ddbfb3ca8c7c9641b76e927b5de206 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 21 May 2026 09:15:07 +0100 Subject: [PATCH 5/8] Add asserts to blocks.ts and gesture monkey patches There are lots more to do in pxtblocks but limiting scope of this change intentionally. --- pxtblocks/monkeyPatches/gesture.ts | 2 ++ pxtblocks/monkeyPatches/util.ts | 6 ++++++ webapp/src/blocks.tsx | 32 +++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 pxtblocks/monkeyPatches/util.ts diff --git a/pxtblocks/monkeyPatches/gesture.ts b/pxtblocks/monkeyPatches/gesture.ts index 94a927906963..e7c138be3379 100644 --- a/pxtblocks/monkeyPatches/gesture.ts +++ b/pxtblocks/monkeyPatches/gesture.ts @@ -1,5 +1,6 @@ import * as Blockly from "blockly"; import { isAllowlistedShadow } from "../plugins/duplicateOnDrag/duplicateOnDrag"; +import { assertMethod } from "./util"; interface PatchedGesture extends Blockly.Gesture { id: number | undefined @@ -14,6 +15,7 @@ interface PatchedGesture extends Blockly.Gesture { */ 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)) { 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/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index c963057c4d41..c8abbea20fb7 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -37,6 +37,7 @@ 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 { getShortcutKeysShort, ShortcutNames } from "./shortcut_formatting"; @@ -522,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(); }; @@ -535,6 +537,7 @@ 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 @@ -546,26 +549,32 @@ export class Editor extends toolboxeditor.ToolboxEditor { 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) { @@ -576,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 @@ -596,8 +607,9 @@ export class Editor extends toolboxeditor.ToolboxEditor { } 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); @@ -605,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(); @@ -615,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) { @@ -630,7 +644,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { opt_volume = themeVolume; } } - oldAudioPlay.call(this, name, opt_volume); + return oldAudioPlay.call(this, name, opt_volume); }; } From 4231c8ce496a601b4b4267464ffd24f60db4b161 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 21 May 2026 09:55:56 +0100 Subject: [PATCH 6/8] Add translator context to accessibility messages Read shortcut key labels from Blockly.Msg in shortcut_formatting.ts so those translations come from initAccessibilityMessages instead of duplicating lf() calls and to keep the local copy close to upstream Blockly's shortcut_formatting.ts. Annotate the ambiguous accessibility messages with {id:...} translator hints (keyboard symbol, block state, block role, block position, field type, checkbox, dropdown, speech bubble) for strings whose meaning isn't clear from the source word alone (e.g. "Checked", "text", "command"). Use {id:keyboard symbol} for keys as is done elsewhere. Align a couple in the keyboard controls help with this approach. --- pxtblocks/loader.ts | 70 +++++++++---------- .../src/components/KeyboardControlsHelp.tsx | 4 +- webapp/src/shortcut_formatting.ts | 52 +++++++------- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/pxtblocks/loader.ts b/pxtblocks/loader.ts index 64339993e50a..60dc6165945a 100644 --- a/pxtblocks/loader.ts +++ b/pxtblocks/loader.ts @@ -793,26 +793,26 @@ function initAccessibilityMessages() { 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("Ctrl"), - COMMAND_KEY: lf("⌘ Command"), - OPTION_KEY: lf("⌥ Option"), - ALT_KEY: lf("Alt"), - ENTER_KEY: lf("Enter"), - BACKSPACE_KEY: lf("Backspace"), - DELETE_KEY: lf("Delete"), - ESCAPE: lf("Esc"), - TAB_KEY: lf("Tab"), - SHIFT_KEY: lf("Shift"), - CAPS_LOCK_KEY: lf("Caps Lock"), - SPACE_KEY: lf("Space"), - PAGE_UP_KEY: lf("Page Up"), - PAGE_DOWN_KEY: lf("Page Down"), - END_KEY: lf("End"), - HOME_KEY: lf("Home"), - INSERT_KEY: lf("Insert"), - PAUSE_KEY: lf("Pause"), - CONTEXT_MENU_KEY: lf("≣ Menu"), - UNNAMED_KEY: lf("unnamed"), + 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"), @@ -841,20 +841,20 @@ function initAccessibilityMessages() { 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("disabled"), - BLOCK_LABEL_COLLAPSED: lf("collapsed"), - BLOCK_LABEL_REPLACEABLE: lf("replaceable"), + 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("command"), - BLOCK_LABEL_CONTAINER: lf("container"), - BLOCK_LABEL_VALUE: lf("value"), + 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("value position"), - INPUT_LABEL_STATEMENT: lf("command position"), + 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. @@ -867,19 +867,19 @@ function initAccessibilityMessages() { ARIA_TYPE_FIELD_CHECKBOX: lf("checkbox"), ARIA_TYPE_FIELD_DROPDOWN: lf("dropdown"), ARIA_TYPE_FIELD_IMAGE: lf("image"), - ARIA_TYPE_FIELD_INPUT: lf("input"), - ARIA_TYPE_FIELD_NUMBER: lf("number"), - ARIA_TYPE_FIELD_TEXT_INPUT: lf("text"), + 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("Checked"), - FIELD_LABEL_CHECKBOX_UNCHECKED: lf("Not checked"), + 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("Option %1"), + 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("Bubble"), + BUBBLE_LABEL_DEFAULT: lf("{id:speech bubble}Bubble"), BUBBLE_LABEL_WARNING: lf("Warning: %1"), // Icon labels. ICON_LABEL_COMMENT_CLOSED: lf("Open Comment"), diff --git a/webapp/src/components/KeyboardControlsHelp.tsx b/webapp/src/components/KeyboardControlsHelp.tsx index cf9ac09f8254..2e8571fc2794 100644 --- a/webapp/src/components/KeyboardControlsHelp.tsx +++ b/webapp/src/components/KeyboardControlsHelp.tsx @@ -8,7 +8,7 @@ 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 = @@ -38,7 +38,7 @@ const KeyboardControlsHelp = () => {

{lf("Editor Overview")}

- + diff --git a/webapp/src/shortcut_formatting.ts b/webapp/src/shortcut_formatting.ts index 63bbcb4d0d0d..fba883bfeaad 100644 --- a/webapp/src/shortcut_formatting.ts +++ b/webapp/src/shortcut_formatting.ts @@ -1,4 +1,4 @@ -import { ShortcutRegistry } from 'blockly'; +import * as Blockly from 'blockly'; const isMacPlatform = pxt.BrowserUtils.isMac(); @@ -65,20 +65,20 @@ export function getShortcutKeysShortAll(shortcutName: string): string[][] { } const longModifierNames: Record = { - 'Control': lf("Ctrl"), - 'Meta': lf("Command"), - 'Alt': isMacPlatform ? lf("Option") : lf("Alt"), + 'Control': Blockly.Msg['CONTROL_KEY'], + 'Meta': Blockly.Msg['COMMAND_KEY'], + 'Alt': isMacPlatform ? Blockly.Msg['OPTION_KEY'] : Blockly.Msg['ALT_KEY'], }; const shortModifierNames: Record = { - 'Control': lf("Ctrl"), + 'Control': Blockly.Msg['CONTROL_KEY'], 'Meta': '⌘', - 'Alt': isMacPlatform ? '⌥' : lf("Alt"), + 'Alt': isMacPlatform ? '⌥' : Blockly.Msg['ALT_KEY'], }; /** - * User-facing name for a keycode. Mirrors Blockly's getKeyName but uses pxt's - * translation function for translatable strings. + * 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) { @@ -87,26 +87,26 @@ function getKeyName(keyCode: number): string { } const keyNames: Record = { - 8: lf("Backspace"), - 9: lf("Tab"), - 13: lf("Enter"), - 16: lf("Shift"), - 17: lf("Ctrl"), - 18: lf("Alt"), - 19: lf("Pause"), - 20: lf("Caps Lock"), - 27: lf("Esc"), - 32: lf("Space"), - 33: lf("Page Up"), - 34: lf("Page Down"), - 35: lf("End"), - 36: lf("Home"), + 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: lf("Insert"), - 46: lf("Delete"), + 45: Blockly.Msg['INSERT_KEY'], + 46: Blockly.Msg['DELETE_KEY'], 48: '0', 49: '1', 50: '2', @@ -119,7 +119,7 @@ function getKeyName(keyCode: number): string { 57: '9', 59: ';', 61: '=', - 93: lf("Context Menu"), + 93: Blockly.Msg['CONTEXT_MENU_KEY'], 96: '0', 97: '1', 98: '2', @@ -183,7 +183,7 @@ function getShortcutKeys( shortcutName: string, modifierNames: Record, ): string[][] { - const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(shortcutName); + const shortcuts = Blockly.ShortcutRegistry.registry.getKeyCodesByShortcutName(shortcutName); if (shortcuts.length === 0) { return []; } From 69d3e46b030276fba33a792a0fe7cbea095ec706 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 21 May 2026 10:16:56 +0100 Subject: [PATCH 7/8] Fix keyboard shortcut help references to use core Blockly names The local ShortcutNames enum was inherited from the blockly-keyboard-experiment plugin and was out of date now those shortcuts have moved into Blockly core. Several entries (TOOLBOX, CREATE_WS_CURSOR, EDIT_OR_CONFIRM, COPY, CUT, PASTE) had stale string values and were silently returning no shortcuts in the help dialog. Replace the enum with a single LIST_SHORTCUTS_SHORTCUT constant and Blockly.ShortcutItems.names. In blocks.tsx, the cleanup-workspace re-registration now keeps Blockly's CLEANUP name. --- pxtblocks/contextMenu/workspaceItems.ts | 4 +- webapp/src/blocks.tsx | 8 +-- .../src/components/KeyboardControlsHelp.tsx | 60 ++++++++++--------- webapp/src/shortcut_formatting.ts | 32 ++-------- 4 files changed, 41 insertions(+), 63 deletions(-) diff --git a/pxtblocks/contextMenu/workspaceItems.ts b/pxtblocks/contextMenu/workspaceItems.ts index bfc9194c17fb..5560263a18b2 100644 --- a/pxtblocks/contextMenu/workspaceItems.ts +++ b/pxtblocks/contextMenu/workspaceItems.ts @@ -54,8 +54,8 @@ function registerFormatCode() { scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, id: 'pxtFormatCode', weight: WorkspaceContextWeight.FormatCode, - // Matches ShortcutNames.CLEAN_UP — re-registered with key F in blocks.tsx. - associatedKeyboardShortcut: 'clean_up_workspace', + // Re-registered with key F by webapp blocks.tsx initKeyboardControls. + associatedKeyboardShortcut: Blockly.ShortcutItems.names.CLEANUP, }; Blockly.ContextMenuRegistry.registry.register(formatOption); } diff --git a/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index c8abbea20fb7..39af345fddae 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -40,7 +40,7 @@ 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 { getShortcutKeysShort, ShortcutNames } from "./shortcut_formatting"; +import { getShortcutKeysShort, LIST_SHORTCUTS_SHORTCUT } from "./shortcut_formatting"; interface CopyDataEntry { version: 1; @@ -650,7 +650,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { private initKeyboardControls() { Blockly.ShortcutRegistry.registry.register({ - name: ShortcutNames.LIST_SHORTCUTS, + name: LIST_SHORTCUTS_SHORTCUT, callback: (workspace) => { Blockly.Toast.hide(workspace, "helpHint"); return true @@ -669,7 +669,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { Blockly.ShortcutRegistry.registry.unregister(cleanUpWorkspace.name); Blockly.ShortcutRegistry.registry.register({ ...cleanUpWorkspace, - name: ShortcutNames.CLEAN_UP, + 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) => { @@ -962,7 +962,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { showKeyboardControlsHint() { if (!this.editor || !Blockly.Msg["HELP_PROMPT"]) return; - const shortcut = getShortcutKeysShort(ShortcutNames.LIST_SHORTCUTS); + const shortcut = getShortcutKeysShort(LIST_SHORTCUTS_SHORTCUT); if (!shortcut) return; Blockly.Toast.show(this.editor, { message: Blockly.Msg["HELP_PROMPT"].replace("%1", shortcut), diff --git a/webapp/src/components/KeyboardControlsHelp.tsx b/webapp/src/components/KeyboardControlsHelp.tsx index 2e8571fc2794..d4351ecd556d 100644 --- a/webapp/src/components/KeyboardControlsHelp.tsx +++ b/webapp/src/components/KeyboardControlsHelp.tsx @@ -1,6 +1,8 @@ import * as React from "react"; -import { getShortcutKeysShortAll, 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 = () => { @@ -11,26 +13,26 @@ const KeyboardControlsHelp = () => { 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: getShortcutKeysShortAll(ShortcutNames.EDIT_OR_CONFIRM), joiner: orAsJoiner} + const enterOrSpace = { shortcuts: getShortcutKeysShortAll(names.PERFORM_ACTION), joiner: orAsJoiner} const editOrConfirmRow = return (
- + - - + + {editOrConfirmRow} - +

{lf("Press arrow keys to move to connections")}

{lf("Hold {0} to move anywhere", optionOrCtrl)}

- + {cleanUpRow} {contextMenuRow}
@@ -40,44 +42,44 @@ const KeyboardControlsHelp = () => { - - - - + + + + - + {cleanUpRow} - - + +

{lf("Edit Blocks")}

- + {editOrConfirmRow} - - - - - - - - + + + + + + + + {contextMenuRow}

{lf("Moving Blocks")}

- - + + {lf("Hold {0} and press arrow keys", optionOrCtrl)} - - + +
diff --git a/webapp/src/shortcut_formatting.ts b/webapp/src/shortcut_formatting.ts index fba883bfeaad..dd0d5901f503 100644 --- a/webapp/src/shortcut_formatting.ts +++ b/webapp/src/shortcut_formatting.ts @@ -3,35 +3,11 @@ import * as Blockly from 'blockly'; const isMacPlatform = pxt.BrowserUtils.isMac(); /** - * Default keyboard navigation shortcut names. - * Based from blockly-keyboard-experiment constants.ts. - * See https://github.com/google/blockly-keyboard-experimentation/blob/main/src/constants.ts + * Name of the shortcut that opens the help dialog. Registered in blocks.tsx + * initKeyboardControls. The literal string is also hardcoded by Blockly core's + * hints.ts (it isn't exported from Blockly.ShortcutItems.names). */ -export enum ShortcutNames { - UP = 'up', - DOWN = 'down', - RIGHT = 'right', - LEFT = 'left', - NEXT_STACK = 'next_stack', - PREVIOUS_STACK = 'previous_stack', - INSERT = 'insert', - EDIT_OR_CONFIRM = 'edit_or_confirm', - DISCONNECT = 'disconnect', - TOOLBOX = 'toolbox', - EXIT = 'exit', - MENU = 'menu', - COPY = 'keyboard_nav_copy', - CUT = 'keyboard_nav_cut', - DUPLICATE = 'duplicate', - PASTE = 'keyboard_nav_paste', - DELETE = 'delete', - CREATE_WS_CURSOR = 'to_workspace', - LIST_SHORTCUTS = 'list_shortcuts', - CLEAN_UP = 'clean_up_workspace', - UNDO = 'undo', - REDO = 'redo', - MOVE = 'start_move', -} +export const LIST_SHORTCUTS_SHORTCUT = 'list_shortcuts'; /** * Mirror of Blockly's getShortcutKeysShort (core/utils/shortcut_formatting.ts). From 590aef8bfecd19fc10a9c1c90bea66b2efd166a6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 21 May 2026 10:44:55 +0100 Subject: [PATCH 8/8] Blockly changed this shortcut There are others to add in a separate PR but this is the only incorrect entry. --- webapp/src/components/KeyboardControlsHelp.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/KeyboardControlsHelp.tsx b/webapp/src/components/KeyboardControlsHelp.tsx index d4351ecd556d..3ace985bdd2d 100644 --- a/webapp/src/components/KeyboardControlsHelp.tsx +++ b/webapp/src/components/KeyboardControlsHelp.tsx @@ -12,7 +12,6 @@ const KeyboardControlsHelp = () => { }, []); const ctrl = lf("{id:keyboard symbol}Ctrl"); const cmd = isMacPlatform ? "⌘" : ctrl; - const optionOrCtrl = isMacPlatform ? "⌥" : ctrl; const contextMenuRow = const cleanUpRow = const orAsJoiner = lf("or") @@ -30,7 +29,7 @@ const KeyboardControlsHelp = () => { {editOrConfirmRow}

{lf("Press arrow keys to move to connections")}

-

{lf("Hold {0} to move anywhere", optionOrCtrl)}

+

{lf("Hold {0} to move anywhere", cmd)}

{cleanUpRow} @@ -75,7 +74,7 @@ const KeyboardControlsHelp = () => { - {lf("Hold {0} and press arrow keys", optionOrCtrl)} + {lf("Hold {0} and press arrow keys", cmd)}