Compare commits
20
Commits
No files matched your search
Generated
+9
@@ -11120,6 +11120,15 @@
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"react-visibility-sensor": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-visibility-sensor/-/react-visibility-sensor-5.0.2.tgz",
|
||||
"integrity": "sha512-7+1lr7oQOO2vKr5u/uxoDGFWAAy7lsDy2aJU3Zy1/OymI+TRqOBk8m2L8YE1B0UyfCDWxVAWO+aifVGqNOvqeQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"react-test-renderer": "16.2.0",
|
||||
"react-tooltip": "3.8.0",
|
||||
"react-virtualized": "9.20.1",
|
||||
"react-visibility-sensor": "5.0.2",
|
||||
"redux": "3.7.2",
|
||||
"redux-mock-store": "^1.2.3",
|
||||
"redux-throttle": "0.1.1",
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import Box from '../box/box.jsx';
|
||||
import PlayButton from '../../containers/play-button.jsx';
|
||||
import ScratchImage from '../scratch-image/scratch-image.jsx';
|
||||
import styles from './library-item.css';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -36,10 +37,12 @@ class LibraryItemComponent extends React.PureComponent {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<img
|
||||
className={styles.featuredImage}
|
||||
src={this.props.iconURL}
|
||||
/>
|
||||
{this.props.iconSource ? (
|
||||
<ScratchImage
|
||||
className={styles.featuredImage}
|
||||
imageSource={this.props.iconSource}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{this.props.insetIconURL ? (
|
||||
<div className={styles.libraryItemInsetImageContainer}>
|
||||
@@ -127,9 +130,9 @@ class LibraryItemComponent extends React.PureComponent {
|
||||
onMouseEnter={this.props.showPlayButton ? this.props.onMouseEnter : null}
|
||||
onMouseLeave={this.props.showPlayButton ? this.props.onMouseLeave : null}
|
||||
>
|
||||
<img
|
||||
<ScratchImage
|
||||
className={styles.libraryItemImage}
|
||||
src={this.props.iconURL}
|
||||
imageSource={this.props.iconSource}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -159,7 +162,7 @@ LibraryItemComponent.propTypes = {
|
||||
extensionId: PropTypes.string,
|
||||
featured: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
iconURL: PropTypes.string,
|
||||
iconSource: ScratchImage.ImageSourcePropType,
|
||||
insetIconURL: PropTypes.string,
|
||||
internetConnectionRequired: PropTypes.bool,
|
||||
isPlaying: PropTypes.bool,
|
||||
|
||||
@@ -10,6 +10,7 @@ import Divider from '../divider/divider.jsx';
|
||||
import Filter from '../filter/filter.jsx';
|
||||
import TagButton from '../../containers/tag-button.jsx';
|
||||
import Spinner from '../spinner/spinner.jsx';
|
||||
import storage from '../../lib/storage';
|
||||
|
||||
import styles from './library.css';
|
||||
|
||||
@@ -29,6 +30,47 @@ const messages = defineMessages({
|
||||
const ALL_TAG = {tag: 'all', intlLabel: messages.allTag};
|
||||
const tagListPrefix = [ALL_TAG];
|
||||
|
||||
/**
|
||||
* Find the AssetType which corresponds to a particular file extension. For example, 'png' => AssetType.ImageBitmap.
|
||||
* @param {string} fileExtension - the file extension to look up.
|
||||
* @returns {AssetType} - the AssetType corresponding to the extension, if any.
|
||||
*/
|
||||
const getAssetTypeForFileExtension = function (fileExtension) {
|
||||
const compareOptions = {
|
||||
sensitivity: 'accent',
|
||||
usage: 'search'
|
||||
};
|
||||
for (const assetTypeId in storage.AssetType) {
|
||||
const assetType = storage.AssetType[assetTypeId];
|
||||
if (fileExtension.localeCompare(assetType.runtimeFormat, compareOptions) === 0) {
|
||||
return assetType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Figure out an `imageSource` (URI or asset ID & type) for a library item's icon.
|
||||
* @param {object} item - either a library item or one of a library item's costumes.
|
||||
* @returns {object} - an `imageSource` ready to be passed to a `ScratchImage`.
|
||||
*/
|
||||
const getItemImageSource = function (item) {
|
||||
if (item.rawURL) {
|
||||
return {
|
||||
uri: item.rawURL
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: adjust libraries to be more storage-friendly; don't use split() here.
|
||||
const md5ext = item.md5 || item.baseLayerMD5;
|
||||
if (md5ext) {
|
||||
const [assetId, fileExtension] = md5ext.split('.');
|
||||
return {
|
||||
assetId: assetId,
|
||||
assetType: getAssetTypeForFileExtension(fileExtension)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
class LibraryComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
@@ -208,8 +250,10 @@ class LibraryComponent extends React.Component {
|
||||
})}
|
||||
ref={this.setFilteredDataRef}
|
||||
>
|
||||
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => (
|
||||
<LibraryItem
|
||||
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => {
|
||||
const iconSource = getItemImageSource(dataItem);
|
||||
const icons = dataItem.json && dataItem.json.costumes.map(getItemImageSource);
|
||||
return (<LibraryItem
|
||||
bluetoothRequired={dataItem.bluetoothRequired}
|
||||
collaborator={dataItem.collaborator}
|
||||
description={dataItem.description}
|
||||
@@ -217,9 +261,8 @@ class LibraryComponent extends React.Component {
|
||||
extensionId={dataItem.extensionId}
|
||||
featured={dataItem.featured}
|
||||
hidden={dataItem.hidden}
|
||||
iconMd5={dataItem.md5}
|
||||
iconRawURL={dataItem.rawURL}
|
||||
icons={dataItem.json && dataItem.json.costumes}
|
||||
iconSource={iconSource}
|
||||
icons={icons}
|
||||
id={index}
|
||||
insetIconURL={dataItem.insetIconURL}
|
||||
internetConnectionRequired={dataItem.internetConnectionRequired}
|
||||
@@ -230,8 +273,8 @@ class LibraryComponent extends React.Component {
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
)) : (
|
||||
/>);
|
||||
}) : (
|
||||
<div className={styles.spinnerWrapper}>
|
||||
<Spinner
|
||||
large
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VisibilitySensor from 'react-visibility-sensor';
|
||||
|
||||
import storage from '../../lib/storage';
|
||||
|
||||
class ScratchImage extends React.PureComponent {
|
||||
static init () {
|
||||
this._maxParallelism = 6;
|
||||
this._currentJobs = 0;
|
||||
this._pendingImages = new Set();
|
||||
}
|
||||
|
||||
static loadPendingImages () {
|
||||
if (this._currentJobs >= this._maxParallelism) {
|
||||
// already busy
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first visible image. If there aren't any, find the first non-visible image.
|
||||
let nextImage;
|
||||
for (const image of this._pendingImages) {
|
||||
if (image.isVisible) {
|
||||
nextImage = image;
|
||||
break;
|
||||
} else {
|
||||
nextImage = nextImage || image;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an image to load:
|
||||
// 1) Remove it from the queue
|
||||
// 2) Load the image
|
||||
// 3) Pump the queue again
|
||||
if (nextImage) {
|
||||
this._pendingImages.delete(nextImage);
|
||||
const imageSource = nextImage.props.imageSource;
|
||||
++this._currentJobs;
|
||||
storage
|
||||
.load(imageSource.assetType, imageSource.assetId)
|
||||
.then(asset => {
|
||||
if (!nextImage.wasUnmounted) {
|
||||
const dataURI = asset.encodeDataURI();
|
||||
|
||||
nextImage.setState({
|
||||
imageURI: dataURI
|
||||
});
|
||||
}
|
||||
--this._currentJobs;
|
||||
this.loadPendingImages();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
Object.assign(this.state, this._loadImageSource(props.imageSource));
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const newState = this._loadImageSource(nextProps.imageSource);
|
||||
this.setState(newState);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.wasUnmounted = true;
|
||||
ScratchImage._pendingImages.delete(this);
|
||||
}
|
||||
/**
|
||||
* Calculate the state changes necessary to load the image specified in the provided source info. If the component
|
||||
* is mounted, call setState() with the return value of this function. If the component has not yet mounted, use
|
||||
* the return value of this function as initial state for the component.
|
||||
*
|
||||
* @param {object} imageSource - the new source for the image, including either assetId or URI
|
||||
* @returns {object} - the new state values, if any.
|
||||
*/
|
||||
_loadImageSource (imageSource) {
|
||||
if (imageSource) {
|
||||
if (imageSource.uri) {
|
||||
ScratchImage._pendingImages.delete(this);
|
||||
return {
|
||||
imageURI: imageSource.uri,
|
||||
lastRequestedAsset: null
|
||||
};
|
||||
}
|
||||
if (this.state.lastRequestedAsset !== imageSource.assetId) {
|
||||
ScratchImage._pendingImages.add(this);
|
||||
return {
|
||||
lastRequestedAsset: imageSource.assetId
|
||||
};
|
||||
}
|
||||
}
|
||||
// Nothing to do - don't change any state.
|
||||
return {};
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
src: _src,
|
||||
imageSource: _imageSource,
|
||||
...imgProps
|
||||
} = this.props;
|
||||
return (
|
||||
<VisibilitySensor
|
||||
intervalCheck
|
||||
scrollCheck
|
||||
>
|
||||
{
|
||||
({isVisible}) => {
|
||||
this.isVisible = isVisible;
|
||||
ScratchImage.loadPendingImages();
|
||||
return (
|
||||
<img
|
||||
src={this.state.imageURI}
|
||||
{...imgProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</VisibilitySensor>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScratchImage.ImageSourcePropType = PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
assetId: PropTypes.string.isRequired,
|
||||
assetType: PropTypes.oneOf(Object.values(storage.AssetType)).isRequired
|
||||
}),
|
||||
PropTypes.shape({
|
||||
uri: PropTypes.string.isRequired
|
||||
})
|
||||
]);
|
||||
|
||||
ScratchImage.propTypes = {
|
||||
imageSource: ScratchImage.ImageSourcePropType.isRequired
|
||||
};
|
||||
|
||||
ScratchImage.init();
|
||||
|
||||
export default ScratchImage;
|
||||
@@ -91,23 +91,17 @@ class LibraryItem extends React.PureComponent {
|
||||
const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length;
|
||||
this.setState({iconIndex: nextIconIndex});
|
||||
}
|
||||
curIconMd5 () {
|
||||
const iconMd5Prop = this.props.iconMd5;
|
||||
curIconSource () {
|
||||
if (this.props.icons &&
|
||||
this.state.isRotatingIcon &&
|
||||
this.state.iconIndex < this.props.icons.length) {
|
||||
const icon = this.props.icons[this.state.iconIndex] || {};
|
||||
return icon.md5ext || // 3.0 library format
|
||||
icon.baseLayerMD5 || // 2.0 library format, TODO GH-5084
|
||||
iconMd5Prop;
|
||||
this.state.iconIndex < this.props.icons.length &&
|
||||
this.props.icons[this.state.iconIndex]) {
|
||||
return this.props.icons[this.state.iconIndex];
|
||||
}
|
||||
return iconMd5Prop;
|
||||
return this.props.iconSource;
|
||||
}
|
||||
render () {
|
||||
const iconMd5 = this.curIconMd5();
|
||||
const iconURL = iconMd5 ?
|
||||
`https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
|
||||
this.props.iconRawURL;
|
||||
const iconSource = this.curIconSource();
|
||||
return (
|
||||
<LibraryItemComponent
|
||||
bluetoothRequired={this.props.bluetoothRequired}
|
||||
@@ -117,8 +111,7 @@ class LibraryItem extends React.PureComponent {
|
||||
extensionId={this.props.extensionId}
|
||||
featured={this.props.featured}
|
||||
hidden={this.props.hidden}
|
||||
iconURL={iconURL}
|
||||
icons={this.props.icons}
|
||||
iconSource={iconSource}
|
||||
id={this.props.id}
|
||||
insetIconURL={this.props.insetIconURL}
|
||||
internetConnectionRequired={this.props.internetConnectionRequired}
|
||||
@@ -149,14 +142,8 @@ LibraryItem.propTypes = {
|
||||
extensionId: PropTypes.string,
|
||||
featured: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
iconMd5: PropTypes.string,
|
||||
iconRawURL: PropTypes.string,
|
||||
icons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
baseLayerMD5: PropTypes.string, // 2.0 library format, TODO GH-5084
|
||||
md5ext: PropTypes.string // 3.0 library format
|
||||
})
|
||||
),
|
||||
iconSource: LibraryItemComponent.propTypes.iconSource, // single icon
|
||||
icons: PropTypes.arrayOf(LibraryItemComponent.propTypes.iconSource), // rotating icons
|
||||
id: PropTypes.number.isRequired,
|
||||
insetIconURL: PropTypes.string,
|
||||
internetConnectionRequired: PropTypes.bool,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import GUI from './containers/gui.jsx';
|
||||
import AppStateHOC from './lib/app-state-hoc.jsx';
|
||||
import TitledHOC from './lib/titled-hoc.jsx';
|
||||
import GuiReducer, {guiInitialState, guiMiddleware, initEmbedded, initFullScreen, initPlayer} from './reducers/gui';
|
||||
import LocalesReducer, {localesInitialState, initLocale} from './reducers/locales';
|
||||
import {ScratchPaintReducer} from 'scratch-paint';
|
||||
@@ -16,6 +17,7 @@ const guiReducers = {
|
||||
export {
|
||||
GUI as default,
|
||||
AppStateHOC,
|
||||
TitledHOC,
|
||||
setAppElement,
|
||||
guiReducers,
|
||||
guiInitialState,
|
||||
|
||||
+19
-6
@@ -1,10 +1,23 @@
|
||||
import GoogleAnalytics from 'react-ga';
|
||||
|
||||
GoogleAnalytics.initialize(process.env.GA_ID || window.GA_ID, {
|
||||
debug: (process.env.NODE_ENV !== 'production'),
|
||||
titleCase: true,
|
||||
sampleRate: (process.env.NODE_ENV === 'production') ? 100 : 0,
|
||||
forceSSL: true
|
||||
});
|
||||
import log from './log';
|
||||
|
||||
const GA_ID = (process.env.GA_ID || window.GA_ID);
|
||||
if (GA_ID) {
|
||||
GoogleAnalytics.initialize(GA_ID, {
|
||||
debug: (process.env.NODE_ENV !== 'production'),
|
||||
titleCase: true,
|
||||
sampleRate: (process.env.NODE_ENV === 'production') ? 100 : 0,
|
||||
forceSSL: true
|
||||
});
|
||||
} else {
|
||||
log.info('Disabling GA because GA_ID is not set.');
|
||||
window.ga = () => {
|
||||
// The `react-ga` module calls this function to implement all Google Analytics calls. Providing an empty
|
||||
// function effectively disables `react-ga`. This is similar to the `testModeAPI` feature of `react-ga` except
|
||||
// that `testModeAPI` logs the arguments of every call into an array. That's nice for testing purposes but
|
||||
// would look like a memory leak in a live program.
|
||||
};
|
||||
}
|
||||
|
||||
export default GoogleAnalytics;
|
||||
@@ -193,7 +193,11 @@ describe('Working with costumes', () => {
|
||||
.mouseMove(abbyElement)
|
||||
.perform();
|
||||
// wait for one of Abby's alternate costumes to appear
|
||||
await findByXpath('//img[@src="https://cdn.assets.scratch.mit.edu/internalapi/asset/b6e23922f23b49ddc6f62f675e77417c.svg/get/"]');
|
||||
const src1 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
|
||||
await driver.sleep(300);
|
||||
const src2 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
|
||||
const sourcesMatch = (src1 === src2);
|
||||
await expect(sourcesMatch).toBeFalsy(); // 'src' attribute should have changed by now
|
||||
const logs = await getLogs();
|
||||
await expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user