From 10f688ea33f7556174f902950a873ddbfda76cc2 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Fri, 19 Apr 2019 09:19:00 -0400 Subject: [PATCH 01/69] Allow GUI consumer to receive a function for triggering saves. Use the same implementation as onSetProjectThumbnailer which does the same thing for getting a thumbnail externally. --- .../scratch-gui/src/lib/project-saver-hoc.jsx | 8 +++++++ .../test/unit/util/project-saver-hoc.test.jsx | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/scratch-gui/src/lib/project-saver-hoc.jsx b/packages/scratch-gui/src/lib/project-saver-hoc.jsx index 911769762a..ee56b4b582 100644 --- a/packages/scratch-gui/src/lib/project-saver-hoc.jsx +++ b/packages/scratch-gui/src/lib/project-saver-hoc.jsx @@ -59,7 +59,12 @@ const ProjectSaverHOC = function (WrappedComponent) { // but then it'd be hard to turn this listening off in our tests window.onbeforeunload = e => this.leavePageConfirm(e); } + + // Allow the GUI consumer to pass in a function to receive a trigger + // for triggering thumbnail or whole project saves. + // These functions are called with null on unmount to prevent stale references. this.props.onSetProjectThumbnailer(this.getProjectThumbnail); + this.props.onSetProjectSaver(this.tryToAutoSave); } componentDidUpdate (prevProps) { if (!this.props.isAnyCreatingNewState && prevProps.isAnyCreatingNewState) { @@ -116,6 +121,7 @@ const ProjectSaverHOC = function (WrappedComponent) { // window.onbeforeunload = undefined; // eslint-disable-line no-undefined // Remove project thumbnailer function since the components are unmounting this.props.onSetProjectThumbnailer(null); + this.props.onSetProjectSaver(null); } leavePageConfirm (e) { if (this.props.projectChanged) { @@ -310,6 +316,7 @@ const ProjectSaverHOC = function (WrappedComponent) { onRemixing, onSetProjectUnchanged, onSetProjectThumbnailer, + onSetProjectSaver, onShowAlert, onShowCopySuccessAlert, onShowRemixSuccessAlert, @@ -376,6 +383,7 @@ const ProjectSaverHOC = function (WrappedComponent) { autoSaveIntervalSecs: 120, onRemixing: () => {}, onSetProjectThumbnailer: () => {}, + onSetProjectSaver: () => {}, onUpdateProjectData: saveProjectToServer }; const mapStateToProps = (state, ownProps) => { diff --git a/packages/scratch-gui/test/unit/util/project-saver-hoc.test.jsx b/packages/scratch-gui/test/unit/util/project-saver-hoc.test.jsx index 1336daef0b..56ed39d44f 100644 --- a/packages/scratch-gui/test/unit/util/project-saver-hoc.test.jsx +++ b/packages/scratch-gui/test/unit/util/project-saver-hoc.test.jsx @@ -465,4 +465,27 @@ describe('projectSaverHOC', () => { expect(setThumb).toHaveBeenCalledTimes(2); expect(setThumb.mock.calls[1][0]).toBe(null); }); + + test('uses onSetProjectSaver on mount/unmount', () => { + const Component = () =>
; + const WrappedComponent = projectSaverHOC(Component); + const setSaver = jest.fn(); + const mounted = mount( + + ); + // Set project saver should be called on mount + expect(setSaver).toHaveBeenCalledTimes(1); + + // And it should not pass that function on to wrapped element + expect(mounted.find(Component).props().onSetProjectSaver).toBeUndefined(); + + // Unmounting should call it again with null + mounted.unmount(); + expect(setSaver).toHaveBeenCalledTimes(2); + expect(setSaver.mock.calls[1][0]).toBe(null); + }); }); From 76561b654635ca263fc5d866b618dc81d33f195f Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Fri, 19 Apr 2019 13:20:08 -0400 Subject: [PATCH 02/69] Remove file menu --- packages/scratch-gui/src/components/menu-bar/menu-bar.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx index f93529c24f..2492114562 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -63,7 +63,6 @@ import styles from './menu-bar.css'; import helpIcon from '../../lib/assets/icon--tutorials.svg'; import mystuffIcon from './icon--mystuff.png'; -import feedbackIcon from './icon--feedback.svg'; import profileIcon from './icon--profile.png'; import remixIcon from './icon--remix.svg'; import dropdownCaret from './dropdown-caret.svg'; @@ -333,6 +332,8 @@ class MenuBar extends React.Component { onClick={this.props.onClickLogo} />
+ {/* + ---- Disable language icon ----
@@ -348,6 +349,7 @@ class MenuBar extends React.Component {
+
+ */}
Date: Fri, 19 Apr 2019 13:24:16 -0400 Subject: [PATCH 03/69] Add home to scratch logo --- .../scratch-gui/src/components/menu-bar/scratch-logo.svg | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/components/menu-bar/scratch-logo.svg b/packages/scratch-gui/src/components/menu-bar/scratch-logo.svg index 28b62731df..931b628ec8 100644 --- a/packages/scratch-gui/src/components/menu-bar/scratch-logo.svg +++ b/packages/scratch-gui/src/components/menu-bar/scratch-logo.svg @@ -1 +1,5 @@ - + + + + + From 7cbea8a91ab06baa8860d6af9b8b9a6cb0528eef Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Mon, 22 Apr 2019 12:50:32 -0400 Subject: [PATCH 04/69] Bring back the library loading through storage. This reverts commit fa44d94da69437da2a61991cd04546099d1eb892. --- packages/scratch-gui/package.json | 1 + .../components/library-item/library-item.jsx | 17 ++- .../src/components/library/library.jsx | 57 ++++++- .../scratch-image/scratch-image.jsx | 139 ++++++++++++++++++ .../src/containers/library-item.jsx | 26 +--- .../test/integration/costumes.test.js | 6 +- 6 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 packages/scratch-gui/src/components/scratch-image/scratch-image.jsx diff --git a/packages/scratch-gui/package.json b/packages/scratch-gui/package.json index cb9e5c452c..02f18df353 100644 --- a/packages/scratch-gui/package.json +++ b/packages/scratch-gui/package.json @@ -100,6 +100,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", 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 5af6fb1595..3ae872e8ad 100644 --- a/packages/scratch-gui/src/components/library-item/library-item.jsx +++ b/packages/scratch-gui/src/components/library-item/library-item.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Box from '../box/box.jsx'; +import ScratchImage from '../scratch-image/scratch-image.jsx'; import styles from './library-item.css'; import classNames from 'classnames'; @@ -35,10 +36,12 @@ class LibraryItemComponent extends React.PureComponent { />
) : null} - + {this.props.iconSource ? ( + + ) : null} {this.props.insetIconURL ? (
@@ -121,9 +124,9 @@ class LibraryItemComponent extends React.PureComponent { {/* Layers of wrapping is to prevent layout thrashing on animation */} - @@ -146,7 +149,7 @@ LibraryItemComponent.propTypes = { extensionId: PropTypes.string, featured: PropTypes.bool, hidden: PropTypes.bool, - iconURL: PropTypes.string, + iconSource: ScratchImage.ImageSourcePropType, insetIconURL: PropTypes.string, internetConnectionRequired: PropTypes.bool, name: PropTypes.oneOfType([ diff --git a/packages/scratch-gui/src/components/library/library.jsx b/packages/scratch-gui/src/components/library/library.jsx index 60f65a06d5..89e820f1b4 100644 --- a/packages/scratch-gui/src/components/library/library.jsx +++ b/packages/scratch-gui/src/components/library/library.jsx @@ -9,6 +9,7 @@ import Modal from '../../containers/modal.jsx'; import Divider from '../divider/divider.jsx'; import Filter from '../filter/filter.jsx'; import TagButton from '../../containers/tag-button.jsx'; +import storage from '../../lib/storage'; import styles from './library.css'; @@ -28,6 +29,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 md5 = item.md5 || item.baseLayerMD5; + if (md5) { + const [assetId, fileExtension] = md5.split('.'); + return { + assetId: assetId, + assetType: getAssetTypeForFileExtension(fileExtension) + }; + } +}; + class LibraryComponent extends React.Component { constructor (props) { super(props); @@ -162,8 +204,10 @@ class LibraryComponent extends React.Component { })} ref={this.setFilteredDataRef} > - {this.getFilteredData().map((dataItem, index) => ( - { + const iconSource = getItemImageSource(dataItem); + const icons = dataItem.json && dataItem.json.costumes.map(getItemImageSource); + return (
); diff --git a/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx b/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx new file mode 100644 index 0000000000..15abe9d3fc --- /dev/null +++ b/packages/scratch-gui/src/components/scratch-image/scratch-image.jsx @@ -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 ( + + { + ({isVisible}) => { + this.isVisible = isVisible; + ScratchImage.loadPendingImages(); + return ( + + ); + } + } + + ); + } +} + +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; diff --git a/packages/scratch-gui/src/containers/library-item.jsx b/packages/scratch-gui/src/containers/library-item.jsx index 6177b916fd..0f770546d3 100644 --- a/packages/scratch-gui/src/containers/library-item.jsx +++ b/packages/scratch-gui/src/containers/library-item.jsx @@ -75,21 +75,17 @@ class LibraryItem extends React.PureComponent { const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length; this.setState({iconIndex: nextIconIndex}); } - curIconMd5 () { + curIconSource () { if (this.props.icons && this.state.isRotatingIcon && this.state.iconIndex < this.props.icons.length && - this.props.icons[this.state.iconIndex] && - this.props.icons[this.state.iconIndex].baseLayerMD5) { - return this.props.icons[this.state.iconIndex].baseLayerMD5; + this.props.icons[this.state.iconIndex]) { + return this.props.icons[this.state.iconIndex]; } - return this.props.iconMd5; + 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 (