From 3bacced30d100e5548d55a24ff0aaeb45417652f Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Thu, 21 May 2026 13:45:46 +0300 Subject: [PATCH] feat: use scratch-storage for loading library thumbnails --- .../components/library-item/library-item.jsx | 20 +---- .../src/components/library/library.jsx | 6 +- .../scratch-image/scratch-image.jsx | 85 +++++++++++++------ 3 files changed, 65 insertions(+), 46 deletions(-) diff --git a/packages/scratch-gui/src/components/library-item/library-item.jsx b/packages/scratch-gui/src/components/library-item/library-item.jsx index 0d2490e997a..8992bc0365c 100644 --- a/packages/scratch-gui/src/components/library-item/library-item.jsx +++ b/packages/scratch-gui/src/components/library-item/library-item.jsx @@ -32,24 +32,10 @@ class LibraryItemComponent extends React.PureComponent { ]); } renderImage (className, imageSource) { - // Scratch Android and Scratch Desktop assume the user is offline and has - // local access to the image assets. In those cases we use the `ScratchImage` - // component which loads the local assets by using a queue. In Scratch Web - // we don't have the assets locally and want to directly download them from - // the assets service. - // TODO: Abstract this logic in the `ScratchImage` component itself. - const url = imageSource.uri ?? imageSource.assetServiceUri; - - if (this.props.platform === PLATFORM.ANDROID || - this.props.platform === PLATFORM.DESKTOP) { - return (); - } - return (); } render () { diff --git a/packages/scratch-gui/src/components/library/library.jsx b/packages/scratch-gui/src/components/library/library.jsx index 9ac67b6d38d..2ce982757bb 100644 --- a/packages/scratch-gui/src/components/library/library.jsx +++ b/packages/scratch-gui/src/components/library/library.jsx @@ -106,8 +106,7 @@ const getItemIcons = function (item) { if (item.assetId && item.dataFormat) { return { assetId: item.assetId, - assetType: getAssetTypeForFileExtension(item.dataFormat), - assetServiceUri: `https://cdn.assets.scratch.mit.edu/internalapi/asset/${item.assetId}.${item.dataFormat}/get/` + assetType: getAssetTypeForFileExtension(item.dataFormat) }; } @@ -116,8 +115,7 @@ const getItemIcons = function (item) { const [assetId, fileExtension] = md5ext.split('.'); return { assetId: assetId, - assetType: getAssetTypeForFileExtension(fileExtension), - assetServiceUri: `https://cdn.assets.scratch.mit.edu/internalapi/asset/${md5ext}/get/` + assetType: getAssetTypeForFileExtension(fileExtension) }; } }; diff --git a/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx b/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx index 831b1f8caab..bc4bbf77151 100644 --- a/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx +++ b/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx @@ -3,12 +3,15 @@ import React from 'react'; import VisibilitySensor from 'react-visibility-sensor'; import {legacyConfig} from '../../legacy-config'; +import {PLATFORM} from '../../lib/platform.js'; +import bindAll from 'lodash.bindall'; class ScratchImage extends React.PureComponent { static init () { this._maxParallelism = 6; this._currentJobs = 0; this._pendingImages = new Set(); + this._assetCache = new Map(); } static loadPendingImages () { @@ -17,13 +20,15 @@ class ScratchImage extends React.PureComponent { return; } - // Find the first visible image. If there aren't any, find the first non-visible image. + // Find the first visible image. Fall back to the first non-visible image only + // when parallelism is capped (desktop/Android), so that off-screen assets are + // eventually pre-loaded as slots free up. let nextImage; for (const image of this._pendingImages) { if (image.isVisible) { nextImage = image; break; - } else { + } else if (this._maxParallelism !== Infinity) { // TODO: Why was this commented out on native branch? nextImage = nextImage || image; } @@ -35,14 +40,15 @@ class ScratchImage extends React.PureComponent { // 3) Pump the queue again if (nextImage) { this._pendingImages.delete(nextImage); - const imageSource = nextImage.props.imageSource; + const assetId = nextImage._pendingAssetId; + const assetType = nextImage._pendingAssetType; ++this._currentJobs; legacyConfig.storage.scratchStorage - .load(imageSource.assetType, imageSource.assetId) + .load(assetType, assetId) .then(asset => { + const dataURI = asset.encodeDataURI(); + ScratchImage._assetCache.set(assetId, dataURI); if (!nextImage.wasUnmounted) { - const dataURI = asset.encodeDataURI(); - nextImage.setState({ imageURI: dataURI }); @@ -55,12 +61,27 @@ class ScratchImage extends React.PureComponent { constructor (props) { super(props); + bindAll(this, [ + 'handleVisibilityChange' + ]); this.state = {}; + if (props.platform === PLATFORM.WEB) { + ScratchImage._maxParallelism = Infinity; + } Object.assign(this.state, this._loadImageSource(props.imageSource)); } componentWillReceiveProps (nextProps) { + if (this.props.platform !== nextProps.platform) { + ScratchImage._maxParallelism = nextProps.platform === PLATFORM.WEB ? Infinity : 6; + } const newState = this._loadImageSource(nextProps.imageSource); this.setState(newState); + // If a new asset was queued and this component is already visible, pump the + // queue immediately so the new frame loads without waiting for a scroll event + // (e.g. icon rotation on hover). + if (newState.lastRequestedAsset && this.isVisible) { + ScratchImage.loadPendingImages(); + } } componentWillUnmount () { this.wasUnmounted = true; @@ -82,7 +103,21 @@ class ScratchImage extends React.PureComponent { lastRequestedAsset: null }; } + const cached = ScratchImage._assetCache.get(imageSource.assetId); + if (cached) { + ScratchImage._pendingImages.delete(this); + return { + imageURI: cached, + lastRequestedAsset: null + }; + } if (this.state.lastRequestedAsset !== imageSource.assetId) { + // Capture assetId/assetType now so loadPendingImages uses the + // correct values. Reading props.imageSource at pop time would + // give the previous frame because componentWillReceiveProps + // fires before React updates this.props. + this._pendingAssetId = imageSource.assetId; + this._pendingAssetType = imageSource.assetType; ScratchImage._pendingImages.add(this); return { lastRequestedAsset: imageSource.assetId @@ -92,11 +127,18 @@ class ScratchImage extends React.PureComponent { // Nothing to do - don't change any state. return {}; } + handleVisibilityChange (isVisible) { + this.isVisible = isVisible; + if (isVisible) { + ScratchImage.loadPendingImages(); + } + } render () { const { // TODO: Does this cause issues for desktop? src: _src, // eslint-disable-line react/prop-types imageSource: _imageSource, + platform: _platform, ...imgProps } = this.props; @@ -104,23 +146,16 @@ class ScratchImage extends React.PureComponent { - { - ({isVisible}) => { - this.isVisible = isVisible; - ScratchImage.loadPendingImages(); - return ( - - ); - } - } + ); } @@ -133,8 +168,7 @@ ScratchImage.ImageSourcePropType = PropTypes.oneOfType([ Object.values( legacyConfig.storage.scratchStorage.AssetType ) - ).isRequired, - assetServiceUri: PropTypes.string.isRequired + ).isRequired }), PropTypes.shape({ uri: PropTypes.string.isRequired @@ -142,7 +176,8 @@ ScratchImage.ImageSourcePropType = PropTypes.oneOfType([ ]); ScratchImage.propTypes = { - imageSource: ScratchImage.ImageSourcePropType.isRequired + imageSource: ScratchImage.ImageSourcePropType.isRequired, + platform: PropTypes.oneOf(Object.values(PLATFORM)) }; ScratchImage.init();