From 5a0f92497d8f36e3172cee384c44f990490936b0 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 7 May 2026 16:59:55 +0900 Subject: [PATCH 1/3] fix(scratch-blocks-v2): resolve post-upstream-merge regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions surfaced after the scratch-blocks v1 → v2 (Blockly v12) upgrade in PR #630. 1. CustomProcedures (ブロックを作る) modal - blocks.jsx: handleCustomProceduresClose now uses `refreshToolboxSelection` (was `refreshToolboxSelection_`) and `getToolbox().scrollToCategory(id)` (was `toolbox_.scrollToCategoryById`). - custom-procedures.jsx: pass `theme` + `scratchTheme` to `ScratchBlocks.inject` so the procedures_declaration block renders with the proper category colours instead of black. - webpack.config.js: also copy `blockly/media/` into `static/blocks-media/` so v2-only assets like `disconnect.mp3` are served (was 404). 2. Extension category does not auto-scroll on add - blocks.jsx: handleExtensionAdded sets `_pendingScrollToCategoryId`; the post-rebuild path in `updateToolbox` looks the category up by `toolboxItemDef_.id` (Blockly v12 ignores the `id` attribute we emit and assigns its own `blockly-XXX` ids), then calls `selectCategoryByName(name)` and `flyout.scrollToCategory(item)`. 3. smalrubyRuby blocks fail to drag from flyout - translations.json: add missing `smalrubyRuby.hashMethodWithBlock` ja / ja-Hira translations. - define-dynamic-block.js: `mutationToDom` now uses `ScratchBlocks.utils.xml.createElement` (XML namespace) and lowercase `blockinfo` attribute. `document.createElement` produces an HTMLUnknownElement which lowercases attribute names through the XMLSerializer round-trip used by Blockly v2 JSON serialization (`extraState`); after the round-trip `getAttribute('blockInfo')` returned `null`, so `domToMutation` aborted early, the block had no inputs/fields, and field/input load reported "Ignoring non-existant field METHOD" / "missing RECEIVER connection". Read side accepts both cases for forward compatibility. --- .../scratch-gui/src/containers/blocks.jsx | 47 ++++++++++++++++++- .../src/containers/custom-procedures.jsx | 22 ++++++++- .../src/lib/define-dynamic-block.js | 19 ++++++-- packages/scratch-gui/webpack.config.js | 21 ++++++++- .../smalruby_ruby/translations.json | 2 + 5 files changed, 102 insertions(+), 9 deletions(-) diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx index 047fd7cf342..e227ee4579d 100644 --- a/packages/scratch-gui/src/containers/blocks.jsx +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -478,6 +478,30 @@ class Blocks extends React.Component { if (!this.props.paletteVisible) { this._applyPaletteVisibility(false); } + + // === Smalruby: scroll the flyout to a newly added extension category. + // `handleExtensionAdded` flags the extension id; once the toolbox has + // been rebuilt (now), look the category up and scroll to it. + const pendingId = this._pendingScrollToCategoryId; + if (pendingId) { + this._pendingScrollToCategoryId = null; + const toolbox = this.workspace?.getToolbox?.(); + const items = toolbox?.getToolboxItems?.() || []; + const item = items.find(it => it.toolboxItemDef_?.id === pendingId); + const name = item?.toolboxItemDef_?.name || item?.name_; + if (item && name) { + if (typeof toolbox.selectCategoryByName === 'function') { + toolbox.selectCategoryByName(name); + } + // ContinuousToolbox.selectCategoryByName updates the toolbox + // selection but does not scroll the flyout. The continuous + // flyout exposes scrollToCategory(item) for that. + const flyout = this.workspace.getFlyout?.(); + if (flyout && typeof flyout.scrollToCategory === 'function') { + flyout.scrollToCategory(item); + } + } + } } withToolboxUpdates (fn) { @@ -1006,6 +1030,18 @@ class Blocks extends React.Component { if (toolboxXML) { this.props.updateToolboxState(toolboxXML); } + + // After the toolbox finishes its async rebuild, scroll the flyout to + // the newly added extension category. In scratch-blocks v1 the flyout + // automatically focused the just-added category, but the v2 + // continuous toolbox does not do this on its own — the flyout stays + // scrolled to wherever it was, so the user never sees the new blocks. + // + // `updateToolboxState` only dispatches the Redux update; the actual + // `workspace.updateToolbox(...)` rebuild happens later from + // `componentDidUpdate` -> `requestToolboxUpdate` (setTimeout 0). Mark + // the pending category and let the post-rebuild path scroll to it. + this._pendingScrollToCategoryId = categoryInfo.id; } handleBlocksInfoUpdate (categoryInfo) { // @todo Later we should replace this to avoid all the warnings from redefining blocks. @@ -1066,8 +1102,15 @@ class Blocks extends React.Component { handleCustomProceduresClose (data) { this.props.onRequestCloseCustomProcedures(data); const ws = this.workspace; - ws.refreshToolboxSelection_(); - ws.toolbox_.scrollToCategoryById('myBlocks'); + // scratch-blocks v2 renamed `refreshToolboxSelection_` → `refreshToolboxSelection` + // and replaced `toolbox_.scrollToCategoryById(id)` with `getToolbox().scrollToCategory(id)`. + if (typeof ws.refreshToolboxSelection === 'function') { + ws.refreshToolboxSelection(); + } + const toolbox = ws.getToolbox?.(); + if (toolbox && typeof toolbox.scrollToCategory === 'function') { + toolbox.scrollToCategory('myBlocks'); + } } handleDrop (dragInfo) { fetch(dragInfo.payload.bodyUrl) diff --git a/packages/scratch-gui/src/containers/custom-procedures.jsx b/packages/scratch-gui/src/containers/custom-procedures.jsx index df03fbecbc3..b0c7e4642c7 100644 --- a/packages/scratch-gui/src/containers/custom-procedures.jsx +++ b/packages/scratch-gui/src/containers/custom-procedures.jsx @@ -5,6 +5,8 @@ import React from 'react'; import CustomProceduresComponent from '../components/custom-procedures/custom-procedures.jsx'; import * as ScratchBlocks from 'scratch-blocks'; import {connect} from 'react-redux'; +import {DEFAULT_MODE, getColorsForMode} from '../lib/settings/color-mode'; +import {CAT_BLOCKS_THEME} from '../lib/settings/theme'; class CustomProcedures extends React.Component { constructor (props) { @@ -46,10 +48,22 @@ class CustomProcedures extends React.Component { setBlocks (blocksRef) { if (!blocksRef) return; this.blocks = blocksRef; + // scratch-blocks v2 renders blocks with the default (black) theme + // unless an explicit theme is supplied to `inject`. The main editor + // workspace passes `theme` + `scratchTheme`; we mirror that here so + // the procedure declaration block in the modal uses the same colours + // as the rest of the editor. const workspaceConfig = defaultsDeep({}, CustomProcedures.defaultOptions, this.props.options, - {rtl: this.props.isRtl} + { + rtl: this.props.isRtl, + theme: new ScratchBlocks.Theme( + this.props.colorMode || DEFAULT_MODE, + getColorsForMode(this.props.colorMode || DEFAULT_MODE), + ), + scratchTheme: this.props.useCatBlocks ? 'catblocks' : 'classic', + } ); // @todo This is a hack to make there be no toolbox. @@ -173,6 +187,8 @@ CustomProcedures.propTypes = { isRtl: PropTypes.bool, mutator: PropTypes.instanceOf(Element), onRequestClose: PropTypes.func.isRequired, + colorMode: PropTypes.string, + useCatBlocks: PropTypes.bool, options: PropTypes.shape({ media: PropTypes.string, zoom: PropTypes.shape({ @@ -202,7 +218,9 @@ CustomProcedures.defaultProps = { const mapStateToProps = state => ({ isRtl: state.locales.isRtl, - mutator: state.scratchGui.customProcedures.mutator + mutator: state.scratchGui.customProcedures.mutator, + colorMode: state.scratchGui.settings.colorMode, + useCatBlocks: state.scratchGui.settings.theme === CAT_BLOCKS_THEME, }); export default connect( diff --git a/packages/scratch-gui/src/lib/define-dynamic-block.js b/packages/scratch-gui/src/lib/define-dynamic-block.js index dee3c7fb4df..87334662167 100644 --- a/packages/scratch-gui/src/lib/define-dynamic-block.js +++ b/packages/scratch-gui/src/lib/define-dynamic-block.js @@ -422,12 +422,25 @@ const defineDynamicBlock = (ScratchBlocks, categoryInfo, staticBlockInfo, extend this.needsBlockInfoUpdate = true; }, mutationToDom: function () { - const container = document.createElement('mutation'); - container.setAttribute('blockInfo', this.blockInfoText); + // `document.createElement('mutation')` creates an HTMLUnknownElement, + // which lowercases attribute names. After XMLSerializer round-trip + // (used by Blockly v2's JSON serialization for `extraState`), the + // camelCase attribute name `blockInfo` becomes `blockinfo`, and + // `getAttribute('blockInfo')` then returns `null` when re-parsed as + // XML. Use Blockly's `utils.xml.createElement` so the element is in + // the XML namespace and attribute case is preserved through the + // round-trip — and also fall back to lowercase `blockinfo` on the + // read side for any state already written with the lowercase form. + const xmlUtils = ScratchBlocks.utils && ScratchBlocks.utils.xml; + const container = xmlUtils && typeof xmlUtils.createElement === 'function' ? + xmlUtils.createElement('mutation') : + document.createElement('mutation'); + container.setAttribute('blockinfo', this.blockInfoText); return container; }, domToMutation: function (xmlElement) { - const blockInfoText = xmlElement.getAttribute('blockInfo'); + const blockInfoText = + xmlElement.getAttribute('blockinfo') || xmlElement.getAttribute('blockInfo'); if (!blockInfoText) return; // === Smalruby: Start of argumentsByMethod support === diff --git a/packages/scratch-gui/webpack.config.js b/packages/scratch-gui/webpack.config.js index 876717d8f31..df9db1a57d4 100644 --- a/packages/scratch-gui/webpack.config.js +++ b/packages/scratch-gui/webpack.config.js @@ -128,13 +128,30 @@ const baseConfig = new ScratchWebpackConfigBuilder( })) .addPlugin(new CopyWebpackPlugin({ patterns: [ + // scratch-blocks v2 (Blockly v12) re-exports some media (e.g. + // `disconnect.mp3`) from the underlying `blockly/media` folder + // rather than re-shipping them in `scratch-blocks/media`. Copy + // those first so that the scratch-blocks-specific assets below + // overwrite anything they need to customise. + { + from: '../../node_modules/blockly/media', + to: 'static/blocks-media/default', + noErrorOnMissing: true + }, + { + from: '../../node_modules/blockly/media', + to: 'static/blocks-media/high-contrast', + noErrorOnMissing: true + }, { from: '../../node_modules/scratch-blocks/media', - to: 'static/blocks-media/default' + to: 'static/blocks-media/default', + force: true }, { from: '../../node_modules/scratch-blocks/media', - to: 'static/blocks-media/high-contrast' + to: 'static/blocks-media/high-contrast', + force: true }, { // overwrite some of the default block media with high-contrast versions diff --git a/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json b/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json index 5ac09491472..2cfe87304e3 100644 --- a/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json +++ b/packages/scratch-vm/src/extensions/smalruby_ruby/translations.json @@ -5,6 +5,7 @@ "smalrubyRuby.arrayMethod": "配列 [RECEIVER] . [METHOD]", "smalrubyRuby.hashMethod": "ハッシュ [RECEIVER] . [METHOD]", "smalrubyRuby.arrayMethodWithBlock": "配列 [RECEIVER] . [METHOD] do", + "smalrubyRuby.hashMethodWithBlock": "ハッシュ [RECEIVER] . [METHOD] do", "smalrubyRuby.numberMethodWithBlock": "数値 [RECEIVER] . [METHOD] do", "smalrubyRuby.blockParam": "ブロックパラメーター [PARAM]", "smalrubyRuby.returnValue": "戻り値", @@ -16,6 +17,7 @@ "smalrubyRuby.arrayMethod": "はいれつ [RECEIVER] . [METHOD]", "smalrubyRuby.hashMethod": "はっしゅ [RECEIVER] . [METHOD]", "smalrubyRuby.arrayMethodWithBlock": "はいれつ [RECEIVER] . [METHOD] do", + "smalrubyRuby.hashMethodWithBlock": "はっしゅ [RECEIVER] . [METHOD] do", "smalrubyRuby.numberMethodWithBlock": "すうち [RECEIVER] . [METHOD] do", "smalrubyRuby.blockParam": "ぶろっくぱらめーたー [PARAM]", "smalrubyRuby.returnValue": "もどりち", From 197e05cd22391837689008d0b583ff082b8b947d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 7 May 2026 17:08:42 +0900 Subject: [PATCH 2/3] fix(custom-procedures): rebuild My Blocks flyout after defining a procedure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createProcedureCallbackFactory` creates the procedures_definition block, but the dynamic `custom="PROCEDURE"` flyout (My Blocks) only shows the new `procedures_call` block after the toolbox is rebuilt. scratch-blocks v1's `ContinuousToolbox.refreshSelection` rebuilt the flyout on every BLOCK_CREATE, so the call block appeared automatically. v2 made `refreshSelection` a no-op, leaving the flyout out of sync — the user defined a custom block, opened My Blocks, and saw only "ブロックを作る" with no call block to drag. Force a `toolbox.forceRerender()` from `handleCustomProceduresClose` (deferred via setTimeout to let pending block-create renders flush), then select + scroll the My Blocks category. Also rename a shadowed `flyout` local that lint flagged. --- .../scratch-gui/src/containers/blocks.jsx | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx index e227ee4579d..32ff4430ff3 100644 --- a/packages/scratch-gui/src/containers/blocks.jsx +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -496,9 +496,9 @@ class Blocks extends React.Component { // ContinuousToolbox.selectCategoryByName updates the toolbox // selection but does not scroll the flyout. The continuous // flyout exposes scrollToCategory(item) for that. - const flyout = this.workspace.getFlyout?.(); - if (flyout && typeof flyout.scrollToCategory === 'function') { - flyout.scrollToCategory(item); + const pendingFlyout = this.workspace.getFlyout?.(); + if (pendingFlyout && typeof pendingFlyout.scrollToCategory === 'function') { + pendingFlyout.scrollToCategory(item); } } } @@ -1102,14 +1102,45 @@ class Blocks extends React.Component { handleCustomProceduresClose (data) { this.props.onRequestCloseCustomProcedures(data); const ws = this.workspace; - // scratch-blocks v2 renamed `refreshToolboxSelection_` → `refreshToolboxSelection` - // and replaced `toolbox_.scrollToCategoryById(id)` with `getToolbox().scrollToCategory(id)`. + // scratch-blocks v2 renamed `refreshToolboxSelection_` → `refreshToolboxSelection`. if (typeof ws.refreshToolboxSelection === 'function') { ws.refreshToolboxSelection(); } + // The new `procedures_definition` block has been created on the + // workspace by `createProcedureCallbackFactory`. The "My Blocks" + // toolbox category is dynamic (`custom="PROCEDURE"`), and its + // `procedures_call` flyout entry only appears after the toolbox is + // rebuilt. In scratch-blocks v1 `ContinuousToolbox.refreshSelection` + // rebuilt the flyout on every BLOCK_CREATE; v2 made that a no-op, + // so we must explicitly force a rebuild here. Defer until after the + // pending block-create renders flush, otherwise `forceRerender` + // sees the workspace mid-update. const toolbox = ws.getToolbox?.(); - if (toolbox && typeof toolbox.scrollToCategory === 'function') { - toolbox.scrollToCategory('myBlocks'); + const myBlocksId = 'myBlocks'; + const scrollMyBlocks = () => { + const items = toolbox?.getToolboxItems?.() || []; + const item = items.find(it => it.toolboxItemDef_?.toolboxitemid === myBlocksId); + const name = item?.toolboxItemDef_?.name || item?.name_; + if (name && typeof toolbox.selectCategoryByName === 'function') { + toolbox.selectCategoryByName(name); + } + const flyout = ws.getFlyout?.(); + if (item && flyout && typeof flyout.scrollToCategory === 'function') { + flyout.scrollToCategory(item); + } + }; + if (toolbox && typeof toolbox.forceRerender === 'function') { + setTimeout(() => { + try { + toolbox.forceRerender(); + } catch (err) { + // forceRerender can throw if dispose paths race; the + // surrounding scroll still works without a rebuild. + } + scrollMyBlocks(); + }, 0); + } else { + scrollMyBlocks(); } } handleDrop (dragInfo) { From 85915e158aac2ca0edadd79ee3f1a21e04510b1f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 7 May 2026 17:14:05 +0900 Subject: [PATCH 3/3] fix(custom-procedures): pass required `id` prop to Modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smalruby's Modal container marks `id` as required (used to push a unique entry into history.state for back-button handling). Upstream omits it on the CustomProcedures modal — which produces a noisy "Failed prop type" warning in the console every time the modal opens. Pass `id="customProceduresModal"` to silence it. --- .../src/components/custom-procedures/custom-procedures.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/scratch-gui/src/components/custom-procedures/custom-procedures.jsx b/packages/scratch-gui/src/components/custom-procedures/custom-procedures.jsx index 30eab853003..6aae3f37aba 100644 --- a/packages/scratch-gui/src/components/custom-procedures/custom-procedures.jsx +++ b/packages/scratch-gui/src/components/custom-procedures/custom-procedures.jsx @@ -24,6 +24,7 @@ const CustomProcedures = props => {