Skip to content

Commit 8a1b104

Browse files
authored
Merge pull request scratchfoundation#4644 from cwillisf/load-library-images-through-storage
Load library images through storage
2 parents 8f35eac + 28cd140 commit 8a1b104

File tree

6 files changed

+213
-33
lines changed

6 files changed

+213
-33
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"react-test-renderer": "16.2.0",
101101
"react-tooltip": "3.8.0",
102102
"react-virtualized": "9.20.1",
103+
"react-visibility-sensor": "5.0.2",
103104
"redux": "3.7.2",
104105
"redux-mock-store": "^1.2.3",
105106
"redux-throttle": "0.1.1",

src/components/library-item/library-item.jsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
33
import React from 'react';
44

55
import Box from '../box/box.jsx';
6+
import ScratchImage from '../scratch-image/scratch-image.jsx';
67
import styles from './library-item.css';
78
import classNames from 'classnames';
89

@@ -35,10 +36,12 @@ class LibraryItemComponent extends React.PureComponent {
3536
/>
3637
</div>
3738
) : null}
38-
<img
39-
className={styles.featuredImage}
40-
src={this.props.iconURL}
41-
/>
39+
{this.props.iconSource ? (
40+
<ScratchImage
41+
className={styles.featuredImage}
42+
imageSource={this.props.iconSource}
43+
/>
44+
) : null}
4245
</div>
4346
{this.props.insetIconURL ? (
4447
<div className={styles.libraryItemInsetImageContainer}>
@@ -121,9 +124,9 @@ class LibraryItemComponent extends React.PureComponent {
121124
{/* Layers of wrapping is to prevent layout thrashing on animation */}
122125
<Box className={styles.libraryItemImageContainerWrapper}>
123126
<Box className={styles.libraryItemImageContainer}>
124-
<img
127+
<ScratchImage
125128
className={styles.libraryItemImage}
126-
src={this.props.iconURL}
129+
imageSource={this.props.iconSource}
127130
/>
128131
</Box>
129132
</Box>
@@ -146,7 +149,7 @@ LibraryItemComponent.propTypes = {
146149
extensionId: PropTypes.string,
147150
featured: PropTypes.bool,
148151
hidden: PropTypes.bool,
149-
iconURL: PropTypes.string,
152+
iconSource: ScratchImage.ImageSourcePropType,
150153
insetIconURL: PropTypes.string,
151154
internetConnectionRequired: PropTypes.bool,
152155
name: PropTypes.oneOfType([

src/components/library/library.jsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Modal from '../../containers/modal.jsx';
99
import Divider from '../divider/divider.jsx';
1010
import Filter from '../filter/filter.jsx';
1111
import TagButton from '../../containers/tag-button.jsx';
12+
import storage from '../../lib/storage';
1213

1314
import styles from './library.css';
1415

@@ -28,6 +29,47 @@ const messages = defineMessages({
2829
const ALL_TAG = {tag: 'all', intlLabel: messages.allTag};
2930
const tagListPrefix = [ALL_TAG];
3031

32+
/**
33+
* Find the AssetType which corresponds to a particular file extension. For example, 'png' => AssetType.ImageBitmap.
34+
* @param {string} fileExtension - the file extension to look up.
35+
* @returns {AssetType} - the AssetType corresponding to the extension, if any.
36+
*/
37+
const getAssetTypeForFileExtension = function (fileExtension) {
38+
const compareOptions = {
39+
sensitivity: 'accent',
40+
usage: 'search'
41+
};
42+
for (const assetTypeId in storage.AssetType) {
43+
const assetType = storage.AssetType[assetTypeId];
44+
if (fileExtension.localeCompare(assetType.runtimeFormat, compareOptions) === 0) {
45+
return assetType;
46+
}
47+
}
48+
};
49+
50+
/**
51+
* Figure out an `imageSource` (URI or asset ID & type) for a library item's icon.
52+
* @param {object} item - either a library item or one of a library item's costumes.
53+
* @returns {object} - an `imageSource` ready to be passed to a `ScratchImage`.
54+
*/
55+
const getItemImageSource = function (item) {
56+
if (item.rawURL) {
57+
return {
58+
uri: item.rawURL
59+
};
60+
}
61+
62+
// TODO: adjust libraries to be more storage-friendly; don't use split() here.
63+
const md5 = item.md5 || item.baseLayerMD5;
64+
if (md5) {
65+
const [assetId, fileExtension] = md5.split('.');
66+
return {
67+
assetId: assetId,
68+
assetType: getAssetTypeForFileExtension(fileExtension)
69+
};
70+
}
71+
};
72+
3173
class LibraryComponent extends React.Component {
3274
constructor (props) {
3375
super(props);
@@ -162,18 +204,19 @@ class LibraryComponent extends React.Component {
162204
})}
163205
ref={this.setFilteredDataRef}
164206
>
165-
{this.getFilteredData().map((dataItem, index) => (
166-
<LibraryItem
207+
{this.getFilteredData().map((dataItem, index) => {
208+
const iconSource = getItemImageSource(dataItem);
209+
const icons = dataItem.json && dataItem.json.costumes.map(getItemImageSource);
210+
return (<LibraryItem
167211
bluetoothRequired={dataItem.bluetoothRequired}
168212
collaborator={dataItem.collaborator}
169213
description={dataItem.description}
170214
disabled={dataItem.disabled}
171215
extensionId={dataItem.extensionId}
172216
featured={dataItem.featured}
173217
hidden={dataItem.hidden}
174-
iconMd5={dataItem.md5}
175-
iconRawURL={dataItem.rawURL}
176-
icons={dataItem.json && dataItem.json.costumes}
218+
iconSource={iconSource}
219+
icons={icons}
177220
id={index}
178221
insetIconURL={dataItem.insetIconURL}
179222
internetConnectionRequired={dataItem.internetConnectionRequired}
@@ -182,8 +225,8 @@ class LibraryComponent extends React.Component {
182225
onMouseEnter={this.handleMouseEnter}
183226
onMouseLeave={this.handleMouseLeave}
184227
onSelect={this.handleSelect}
185-
/>
186-
))}
228+
/>);
229+
})}
187230
</div>
188231
</Modal>
189232
);
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import PropTypes from 'prop-types';
2+
import React from 'react';
3+
import VisibilitySensor from 'react-visibility-sensor';
4+
5+
import storage from '../../lib/storage';
6+
7+
class ScratchImage extends React.PureComponent {
8+
static init () {
9+
this._maxParallelism = 6;
10+
this._currentJobs = 0;
11+
this._pendingImages = new Set();
12+
}
13+
14+
static loadPendingImages () {
15+
if (this._currentJobs >= this._maxParallelism) {
16+
// already busy
17+
return;
18+
}
19+
20+
// Find the first visible image. If there aren't any, find the first non-visible image.
21+
let nextImage;
22+
for (const image of this._pendingImages) {
23+
if (image.isVisible) {
24+
nextImage = image;
25+
break;
26+
} else {
27+
nextImage = nextImage || image;
28+
}
29+
}
30+
31+
// If we found an image to load:
32+
// 1) Remove it from the queue
33+
// 2) Load the image
34+
// 3) Pump the queue again
35+
if (nextImage) {
36+
this._pendingImages.delete(nextImage);
37+
const imageSource = nextImage.props.imageSource;
38+
++this._currentJobs;
39+
storage
40+
.load(imageSource.assetType, imageSource.assetId)
41+
.then(asset => {
42+
if (!nextImage.wasUnmounted) {
43+
const dataURI = asset.encodeDataURI();
44+
45+
nextImage.setState({
46+
imageURI: dataURI
47+
});
48+
}
49+
--this._currentJobs;
50+
this.loadPendingImages();
51+
});
52+
}
53+
}
54+
55+
constructor (props) {
56+
super(props);
57+
this.state = {};
58+
Object.assign(this.state, this._loadImageSource(props.imageSource));
59+
}
60+
componentWillReceiveProps (nextProps) {
61+
const newState = this._loadImageSource(nextProps.imageSource);
62+
this.setState(newState);
63+
}
64+
componentWillUnmount () {
65+
this.wasUnmounted = true;
66+
ScratchImage._pendingImages.delete(this);
67+
}
68+
/**
69+
* Calculate the state changes necessary to load the image specified in the provided source info. If the component
70+
* is mounted, call setState() with the return value of this function. If the component has not yet mounted, use
71+
* the return value of this function as initial state for the component.
72+
*
73+
* @param {object} imageSource - the new source for the image, including either assetId or URI
74+
* @returns {object} - the new state values, if any.
75+
*/
76+
_loadImageSource (imageSource) {
77+
if (imageSource) {
78+
if (imageSource.uri) {
79+
ScratchImage._pendingImages.delete(this);
80+
return {
81+
imageURI: imageSource.uri,
82+
lastRequestedAsset: null
83+
};
84+
}
85+
if (this.state.lastRequestedAsset !== imageSource.assetId) {
86+
ScratchImage._pendingImages.add(this);
87+
return {
88+
lastRequestedAsset: imageSource.assetId
89+
};
90+
}
91+
}
92+
// Nothing to do - don't change any state.
93+
return {};
94+
}
95+
render () {
96+
const {
97+
src: _src,
98+
imageSource: _imageSource,
99+
...imgProps
100+
} = this.props;
101+
return (
102+
<VisibilitySensor
103+
intervalCheck
104+
scrollCheck
105+
>
106+
{
107+
({isVisible}) => {
108+
this.isVisible = isVisible;
109+
ScratchImage.loadPendingImages();
110+
return (
111+
<img
112+
src={this.state.imageURI}
113+
{...imgProps}
114+
/>
115+
);
116+
}
117+
}
118+
</VisibilitySensor>
119+
);
120+
}
121+
}
122+
123+
ScratchImage.ImageSourcePropType = PropTypes.oneOfType([
124+
PropTypes.shape({
125+
assetId: PropTypes.string.isRequired,
126+
assetType: PropTypes.oneOf(Object.values(storage.AssetType)).isRequired
127+
}),
128+
PropTypes.shape({
129+
uri: PropTypes.string.isRequired
130+
})
131+
]);
132+
133+
ScratchImage.propTypes = {
134+
imageSource: ScratchImage.ImageSourcePropType.isRequired
135+
};
136+
137+
ScratchImage.init();
138+
139+
export default ScratchImage;

src/containers/library-item.jsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,17 @@ class LibraryItem extends React.PureComponent {
7575
const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length;
7676
this.setState({iconIndex: nextIconIndex});
7777
}
78-
curIconMd5 () {
78+
curIconSource () {
7979
if (this.props.icons &&
8080
this.state.isRotatingIcon &&
8181
this.state.iconIndex < this.props.icons.length &&
82-
this.props.icons[this.state.iconIndex] &&
83-
this.props.icons[this.state.iconIndex].baseLayerMD5) {
84-
return this.props.icons[this.state.iconIndex].baseLayerMD5;
82+
this.props.icons[this.state.iconIndex]) {
83+
return this.props.icons[this.state.iconIndex];
8584
}
86-
return this.props.iconMd5;
85+
return this.props.iconSource;
8786
}
8887
render () {
89-
const iconMd5 = this.curIconMd5();
90-
const iconURL = iconMd5 ?
91-
`https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
92-
this.props.iconRawURL;
88+
const iconSource = this.curIconSource();
9389
return (
9490
<LibraryItemComponent
9591
bluetoothRequired={this.props.bluetoothRequired}
@@ -99,8 +95,7 @@ class LibraryItem extends React.PureComponent {
9995
extensionId={this.props.extensionId}
10096
featured={this.props.featured}
10197
hidden={this.props.hidden}
102-
iconURL={iconURL}
103-
icons={this.props.icons}
98+
iconSource={iconSource}
10499
id={this.props.id}
105100
insetIconURL={this.props.insetIconURL}
106101
internetConnectionRequired={this.props.internetConnectionRequired}
@@ -127,13 +122,8 @@ LibraryItem.propTypes = {
127122
extensionId: PropTypes.string,
128123
featured: PropTypes.bool,
129124
hidden: PropTypes.bool,
130-
iconMd5: PropTypes.string,
131-
iconRawURL: PropTypes.string,
132-
icons: PropTypes.arrayOf(
133-
PropTypes.shape({
134-
baseLayerMD5: PropTypes.string
135-
})
136-
),
125+
iconSource: LibraryItemComponent.propTypes.iconSource, // single icon
126+
icons: PropTypes.arrayOf(LibraryItemComponent.propTypes.iconSource), // rotating icons
137127
id: PropTypes.number.isRequired,
138128
insetIconURL: PropTypes.string,
139129
internetConnectionRequired: PropTypes.bool,

test/integration/costumes.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,11 @@ describe('Working with costumes', () => {
193193
.mouseMove(abbyElement)
194194
.perform();
195195
// wait for one of Abby's alternate costumes to appear
196-
await findByXpath('//img[@src="https://cdn.assets.scratch.mit.edu/internalapi/asset/b6e23922f23b49ddc6f62f675e77417c.svg/get/"]');
196+
const src1 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
197+
await driver.sleep(300);
198+
const src2 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
199+
const sourcesMatch = (src1 === src2);
200+
await expect(sourcesMatch).toBeFalsy(); // 'src' attribute should have changed by now
197201
const logs = await getLogs();
198202
await expect(logs).toEqual([]);
199203
});

0 commit comments

Comments
 (0)