From c1e6097d00eaaec1f4346e267b8cee3db4fec27b Mon Sep 17 00:00:00 2001 From: Krum Angelov Date: Wed, 4 Feb 2026 16:10:07 +0200 Subject: [PATCH 1/4] chore: added blur on document click logic to prevent wrong focus on tab --- packages/scratch-gui/src/components/gui/gui.jsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 0452e9ba4c..e1382fbe24 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -207,6 +207,20 @@ const GUIComponent = props => { return {children}; } + useEffect(() => { + const handleDocumentClick = () => { + // If any element is focused, blur it. + // Usually handled automatically but canvas clicks don't seem to blur previous focus. + if (document.activeElement) { + document.activeElement.blur(); + } + }; + document.addEventListener('mousedown', handleDocumentClick); + return () => { + document.removeEventListener('mousedown', handleDocumentClick); + }; + }, []); + useEffect(() => { if (props.platform) { // TODO: This uses the imported `setPlatform` directly, From 9ad8cc710d10add101022e0f4d69d50601c7b2a5 Mon Sep 17 00:00:00 2001 From: Krum Angelov Date: Thu, 5 Feb 2026 15:31:48 +0200 Subject: [PATCH 2/4] chore: improved if statement --- packages/scratch-gui/src/components/gui/gui.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index e1382fbe24..8b30072685 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -211,7 +211,7 @@ const GUIComponent = props => { const handleDocumentClick = () => { // If any element is focused, blur it. // Usually handled automatically but canvas clicks don't seem to blur previous focus. - if (document.activeElement) { + if (document.activeElement !== document.body) { document.activeElement.blur(); } }; From 156b4b44e15e90c40a664b76f62e645d80138029 Mon Sep 17 00:00:00 2001 From: Krum Angelov Date: Thu, 5 Feb 2026 15:51:58 +0200 Subject: [PATCH 3/4] chore: moved specific useEffect logic to costume tab component --- packages/scratch-gui/src/components/gui/gui.jsx | 14 -------------- .../scratch-gui/src/containers/costume-tab.jsx | 13 +++++++++++++ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 8b30072685..0452e9ba4c 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -207,20 +207,6 @@ const GUIComponent = props => { return {children}; } - useEffect(() => { - const handleDocumentClick = () => { - // If any element is focused, blur it. - // Usually handled automatically but canvas clicks don't seem to blur previous focus. - if (document.activeElement !== document.body) { - document.activeElement.blur(); - } - }; - document.addEventListener('mousedown', handleDocumentClick); - return () => { - document.removeEventListener('mousedown', handleDocumentClick); - }; - }, []); - useEffect(() => { if (props.platform) { // TODO: This uses the imported `setPlatform` directly, diff --git a/packages/scratch-gui/src/containers/costume-tab.jsx b/packages/scratch-gui/src/containers/costume-tab.jsx index a475e59ab7..c57c671bf7 100644 --- a/packages/scratch-gui/src/containers/costume-tab.jsx +++ b/packages/scratch-gui/src/containers/costume-tab.jsx @@ -105,6 +105,15 @@ class CostumeTab extends React.Component { this.state = {selectedCostumeIndex: 0}; } } + componentDidMount () { + this.handleDocumentClick = () => { + if (document.activeElement !== document.body) { + document.activeElement.blur(); + } + }; + document.addEventListener('mousedown', this.handleDocumentClick); + } + componentWillReceiveProps (nextProps) { const { editingTarget, @@ -133,6 +142,10 @@ class CostumeTab extends React.Component { this.setState({selectedCostumeIndex: target.currentCostume}); } } + + componentWillUnmount () { + document.removeEventListener('mousedown', this.handleDocumentClick); + } static contextType = ModalFocusContext; handleSelectCostume (costumeIndex) { From 8087a874b60efae5eba1eed89dabd4672196ad2d Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Mon, 9 Feb 2026 15:22:48 +0200 Subject: [PATCH 4/4] fix: merge editor assets and dynamic assets when using the surprise button --- .../src/containers/costume-tab.jsx | 44 ++++++++++++++++--- .../scratch-gui/src/containers/sound-tab.jsx | 28 ++++++++++-- .../src/containers/stage-selector.jsx | 28 ++++++++++-- .../src/containers/target-pane.jsx | 25 +++++++++-- 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/scratch-gui/src/containers/costume-tab.jsx b/packages/scratch-gui/src/containers/costume-tab.jsx index c57c671bf7..196474fd20 100644 --- a/packages/scratch-gui/src/containers/costume-tab.jsx +++ b/packages/scratch-gui/src/containers/costume-tab.jsx @@ -38,6 +38,8 @@ import searchIcon from '../components/action-menu/icon--search.svg'; import costumeLibraryContent from '../lib/libraries/costumes.json'; import backdropLibraryContent from '../lib/libraries/backdrops.json'; import {ModalFocusContext} from '../contexts/modal-focus-context.jsx'; +import {costumeShape} from '../lib/assets-prop-types.js'; +import mergeDynamicAssets from '../lib/merge-dynamic-assets.js'; let messages = defineMessages({ addLibraryBackdropMsg: { @@ -91,7 +93,9 @@ class CostumeTab extends React.Component { 'handleFileUploadClick', 'handleCostumeUpload', 'handleDrop', - 'setFileInput' + 'setFileInput', + 'mergeDynamicCostumes', + 'mergeDynamicBackdrops' ]); const { editingTarget, @@ -104,6 +108,8 @@ class CostumeTab extends React.Component { } else { this.state = {selectedCostumeIndex: 0}; } + this.processedCostumes = {}; + this.processedBackdrops = {}; } componentDidMount () { this.handleDocumentClick = () => { @@ -148,6 +154,26 @@ class CostumeTab extends React.Component { } static contextType = ModalFocusContext; + mergeDynamicCostumes () { + if (this.processedCostumes.source === this.props.dynamicCostumes) { + return this.processedCostumes.data; + } + this.processedCostumes = mergeDynamicAssets( + costumeLibraryContent, + this.props.dynamicCostumes + ); + return this.processedCostumes.data; + } + mergeDynamicBackdrops () { + if (this.processedBackdrops.source === this.props.dynamicBackdrops) { + return this.processedBackdrops.data; + } + this.processedBackdrops = mergeDynamicAssets( + backdropLibraryContent, + this.props.dynamicBackdrops + ); + return this.processedBackdrops.data; + } handleSelectCostume (costumeIndex) { this.props.vm.editingTarget.setCostume(costumeIndex); this.setState({selectedCostumeIndex: costumeIndex}); @@ -203,7 +229,9 @@ class CostumeTab extends React.Component { this.handleNewCostume(emptyCostume(name)); } handleSurpriseCostume () { - const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; + const costumes = this.mergeDynamicCostumes(); + + const item = costumes[Math.floor(Math.random() * costumes.length)]; const vmCostume = { name: item.name, md5: item.md5ext, @@ -215,7 +243,9 @@ class CostumeTab extends React.Component { this.handleNewCostume(vmCostume, true /* fromCostumeLibrary */); } handleSurpriseBackdrop () { - const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + const backdrops = this.mergeDynamicBackdrops(); + + const item = backdrops[Math.floor(Math.random() * backdrops.length)]; const vmCostume = { name: item.name, md5: item.md5ext, @@ -388,7 +418,9 @@ CostumeTab.propTypes = { name: PropTypes.string.isRequired })) }), - vm: PropTypes.instanceOf(VM) + vm: PropTypes.instanceOf(VM), + dynamicCostumes: PropTypes.arrayOf(costumeShape), + dynamicBackdrops: PropTypes.arrayOf(costumeShape) }; const mapStateToProps = state => ({ @@ -396,7 +428,9 @@ const mapStateToProps = state => ({ isRtl: state.locales.isRtl, sprites: state.scratchGui.targets.sprites, stage: state.scratchGui.targets.stage, - dragging: state.scratchGui.assetDrag.dragging + dragging: state.scratchGui.assetDrag.dragging, + dynamicCostumes: state.scratchGui.dynamicAssets.costumes, + dynamicBackdrops: state.scratchGui.dynamicAssets.backdrops }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/containers/sound-tab.jsx b/packages/scratch-gui/src/containers/sound-tab.jsx index 6e4b17ca0c..63b5386baf 100644 --- a/packages/scratch-gui/src/containers/sound-tab.jsx +++ b/packages/scratch-gui/src/containers/sound-tab.jsx @@ -41,6 +41,9 @@ import {setRestore} from '../reducers/restore-deletion'; import {showStandardAlert, closeAlertWithId} from '../reducers/alerts'; import {ModalFocusContext} from '../contexts/modal-focus-context.jsx'; +import {soundShape} from '../lib/assets-prop-types.js'; +import mergeDynamicAssets from '../lib/merge-dynamic-assets.js'; + class SoundTab extends React.Component { constructor (props) { super(props); @@ -55,9 +58,11 @@ class SoundTab extends React.Component { 'handleSoundUpload', 'handleNewSoundFromLibraryClick', 'handleDrop', - 'setFileInput' + 'setFileInput', + 'mergeDynamicAssets' ]); this.state = {selectedSoundIndex: 0}; + this.processedSounds = {}; } componentWillReceiveProps (nextProps) { @@ -82,6 +87,17 @@ class SoundTab extends React.Component { static contextType = ModalFocusContext; + mergeDynamicAssets () { + if (this.processedSounds.source === this.props.dynamicSounds) { + return this.processedSounds.data; + } + this.processedSounds = mergeDynamicAssets( + soundLibraryContent, + this.props.dynamicSounds + ); + return this.processedSounds.data; + } + handleSelectSound (soundIndex) { this.setState({selectedSoundIndex: soundIndex}); } @@ -116,7 +132,9 @@ class SoundTab extends React.Component { } handleSurpriseSound () { - const soundItem = soundLibraryContent[Math.floor(Math.random() * soundLibraryContent.length)]; + const sounds = this.mergeDynamicAssets(); + + const soundItem = sounds[Math.floor(Math.random() * sounds.length)]; const vmSound = { format: soundItem.dataFormat, md5: soundItem.md5ext, @@ -317,7 +335,8 @@ SoundTab.propTypes = { name: PropTypes.string.isRequired })) }), - vm: PropTypes.instanceOf(VM).isRequired + vm: PropTypes.instanceOf(VM).isRequired, + dynamicSounds: PropTypes.arrayOf(soundShape) }; const mapStateToProps = state => ({ @@ -326,7 +345,8 @@ const mapStateToProps = state => ({ sprites: state.scratchGui.targets.sprites, stage: state.scratchGui.targets.stage, soundLibraryVisible: state.scratchGui.modals.soundLibrary, - soundRecorderVisible: state.scratchGui.modals.soundRecorder + soundRecorderVisible: state.scratchGui.modals.soundRecorder, + dynamicSounds: state.scratchGui.dynamicAssets.sounds }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/containers/stage-selector.jsx b/packages/scratch-gui/src/containers/stage-selector.jsx index 5197bccbec..aa41ffd863 100644 --- a/packages/scratch-gui/src/containers/stage-selector.jsx +++ b/packages/scratch-gui/src/containers/stage-selector.jsx @@ -25,6 +25,9 @@ import backdropLibraryContent from '../lib/libraries/backdrops.json'; import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; import {ModalFocusContext} from '../contexts/modal-focus-context.jsx'; +import {costumeShape as backdropShape} from '../lib/assets-prop-types.js'; +import mergeDynamicAssets from '../lib/merge-dynamic-assets.js'; + const dragTypes = [ DragConstants.COSTUME, DragConstants.SOUND, @@ -54,8 +57,11 @@ class StageSelector extends React.Component { 'handleTouchEnd', 'handleDrop', 'setFileInput', - 'setRef' + 'setRef', + 'mergeDynamicAssets' ]); + + this.processedBackdrops = {}; } componentDidMount () { document.addEventListener('touchend', this.handleTouchEnd); @@ -66,6 +72,16 @@ class StageSelector extends React.Component { static contextType = ModalFocusContext; + mergeDynamicAssets () { + if (this.processedBackdrops.source === this.props.dynamicBackdrops) { + return this.processedBackdrops.data; + } + this.processedBackdrops = mergeDynamicAssets( + backdropLibraryContent, + this.props.dynamicBackdrops + ); + return this.processedBackdrops.data; + } handleTouchEnd (e) { const {x, y} = getEventXY(e); const {top, left, bottom, right} = this.ref.getBoundingClientRect(); @@ -107,7 +123,9 @@ class StageSelector extends React.Component { handleSurpriseBackdrop (e) { e.stopPropagation(); // Prevent click from falling through to selecting stage. // @todo should this not add a backdrop you already have? - const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + const backdrops = this.mergeDynamicAssets(); + + const item = backdrops[Math.floor(Math.random() * backdrops.length)]; this.addBackdropFromLibraryItem(item, false); } handleEmptyBackdrop (e) { @@ -198,7 +216,8 @@ StageSelector.propTypes = { intl: intlShape.isRequired, onCloseImporting: PropTypes.func, onSelect: PropTypes.func, - onShowImporting: PropTypes.func + onShowImporting: PropTypes.func, + dynamicBackdrops: PropTypes.arrayOf(backdropShape) }; const mapStateToProps = (state, {asset, id}) => ({ @@ -206,7 +225,8 @@ const mapStateToProps = (state, {asset, id}) => ({ vm: state.scratchGui.vm, receivedBlocks: state.scratchGui.hoveredTarget.receivedBlocks && state.scratchGui.hoveredTarget.sprite === id, - raised: state.scratchGui.blockDrag + raised: state.scratchGui.blockDrag, + dynamicBackdrops: state.scratchGui.dynamicAssets.backdrops }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/containers/target-pane.jsx b/packages/scratch-gui/src/containers/target-pane.jsx index 501ca7fa25..26abd6def5 100644 --- a/packages/scratch-gui/src/containers/target-pane.jsx +++ b/packages/scratch-gui/src/containers/target-pane.jsx @@ -25,6 +25,8 @@ import {fetchSprite, fetchCode} from '../lib/backpack-api'; import randomizeSpritePosition from '../lib/randomize-sprite-position'; import downloadBlob from '../lib/download-blob'; import {ModalFocusContext} from '../contexts/modal-focus-context.jsx'; +import {spriteShape} from '../lib/assets-prop-types.js'; +import mergeDynamicAssets from '../lib/merge-dynamic-assets.js'; class TargetPane extends React.Component { constructor (props) { @@ -50,8 +52,11 @@ class TargetPane extends React.Component { 'handlePaintSpriteClick', 'handleFileUploadClick', 'handleSpriteUpload', - 'setFileInput' + 'setFileInput', + 'mergeDynamicAssets' ]); + + this.processedSprites = {}; } componentDidMount () { this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd); @@ -62,6 +67,16 @@ class TargetPane extends React.Component { static contextType = ModalFocusContext; + mergeDynamicAssets () { + if (this.processedSprites.source === this.props.dynamicSprites) { + return this.processedSprites.data; + } + this.processedSprites = mergeDynamicAssets( + spriteLibraryContent, + this.props.dynamicSprites + ); + return this.processedSprites.data; + } handleChangeSpriteDirection (direction) { this.props.vm.postSpriteInfo({direction}); } @@ -112,7 +127,9 @@ class TargetPane extends React.Component { } } handleSurpriseSpriteClick () { - const surpriseSprites = spriteLibraryContent.filter(sprite => + const sprites = this.mergeDynamicAssets(); + + const surpriseSprites = sprites.filter(sprite => (sprite.tags.indexOf('letters') === -1) && (sprite.tags.indexOf('numbers') === -1) ); const item = surpriseSprites[Math.floor(Math.random() * surpriseSprites.length)]; @@ -296,6 +313,7 @@ TargetPane.propTypes = { intl: intlShape.isRequired, onCloseImporting: PropTypes.func, onShowImporting: PropTypes.func, + dynamicSprites: PropTypes.arrayOf(spriteShape), ...targetPaneProps }; @@ -307,7 +325,8 @@ const mapStateToProps = state => ({ sprites: state.scratchGui.targets.sprites, stage: state.scratchGui.targets.stage, raiseSprites: state.scratchGui.blockDrag, - workspaceMetrics: state.scratchGui.workspaceMetrics + workspaceMetrics: state.scratchGui.workspaceMetrics, + dynamicSprites: state.scratchGui.dynamicAssets.sprites }); const mapDispatchToProps = dispatch => ({