diff --git a/package.json b/package.json index 8a01d833e17bbe46d36c014ce4eea40a3833c246..9e7a0cfc782521d820e95d341e586af6f9a1a23d 100644 --- a/package.json +++ b/package.json @@ -105,10 +105,10 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.20190114210212", - "scratch-blocks": "0.1.0-prerelease.1557852594", - "scratch-l10n": "3.3.20190522144128", + "scratch-l10n": "3.3.20190612144059", + "scratch-blocks": "0.1.0-prerelease.1559136797", "scratch-paint": "0.2.0-prerelease.20190524133325", - "scratch-render": "0.1.0-prerelease.20190524125950", + "scratch-render": "0.1.0-prerelease.20190605151415", "scratch-storage": "1.3.2", "scratch-svg-renderer": "0.2.0-prerelease.20190523193400", "scratch-vm": "0.2.0-prerelease.20190618225856", diff --git a/src/components/cards/card.css b/src/components/cards/card.css index a0a8b88d54bae5234a4be3ee797f8f076054f06d..265ca22d1527f3c460e9e7724177eac0c33cf7fc 100644 --- a/src/components/cards/card.css +++ b/src/components/cards/card.css @@ -162,6 +162,7 @@ } .step-body { + width: 100%; background: $ui-white; display: flex; flex-direction: column; @@ -177,6 +178,8 @@ .step-image { max-width: 450px; + max-height: 200px; + object-fit: contain; background: #F9F9F9; border: 1px solid #ddd; border-radius: 0.5rem; @@ -275,14 +278,6 @@ margin-right: 0.5rem; } -.video-cover { - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; -} - .steps-list { display: flex; flex-direction: row; diff --git a/src/components/cards/cards.jsx b/src/components/cards/cards.jsx index 83e40bf49a173449ed29bf3fffd763829cdecbb9..9bb147f130f4c1f74e2d91bff34bc3a80c949f07 100644 --- a/src/components/cards/cards.jsx +++ b/src/components/cards/cards.jsx @@ -85,32 +85,64 @@ const CardHeader = ({onCloseCards, onShrinkExpandCards, onShowAll, totalSteps, s </div> ); -// Video step needs to know if the card is being dragged to cover the video -// so that the mouseup is not swallowed by the iframe. -const VideoStep = ({video, dragging}) => ( - <div className={styles.stepVideo}> - {dragging ? ( - <div className={styles.videoCover} /> - ) : null} - <iframe - allowFullScreen - allowTransparency="true" - frameBorder="0" - height="257" - scrolling="no" - src={`https://fast.wistia.net/embed/iframe/${video}?seo=false&videoFoam=true`} - title="📹" - width="466" - /> - <script - async - src="https://fast.wistia.net/assets/external/E-v1.js" - /> - </div> -); +class VideoStep extends React.Component { + + componentDidMount () { + const script = document.createElement('script'); + script.src = `https://fast.wistia.com/embed/medias/${this.props.video}.jsonp`; + script.async = true; + script.setAttribute('id', 'wistia-video-content'); + document.body.appendChild(script); + + const script2 = document.createElement('script'); + script2.src = 'https://fast.wistia.com/assets/external/E-v1.js'; + script2.async = true; + script2.setAttribute('id', 'wistia-video-api'); + document.body.appendChild(script2); + } + + // We use the Wistia API here to update or pause the video dynamically: + // https://wistia.com/support/developers/player-api + componentDidUpdate (prevProps) { + // Get a handle on the currently loaded video + const video = window.Wistia.api(prevProps.video); + + // Reset the video source if a new video has been chosen from the library + if (prevProps.video !== this.props.video) { + video.replaceWith(this.props.video); + } + + // Pause the video if the modal is being shrunken + if (!this.props.expanded) { + video.pause(); + } + } + + componentWillUnmount () { + const script = document.getElementById('wistia-video-content'); + script.parentNode.removeChild(script); + + const script2 = document.getElementById('wistia-video-api'); + script2.parentNode.removeChild(script2); + } + + render () { + return ( + <div className={styles.stepVideo}> + <div + className={`wistia_embed wistia_async_${this.props.video}`} + id="video-div" + style={{height: `257px`, width: `466px`}} + > + + </div> + </div> + ); + } +} VideoStep.propTypes = { - dragging: PropTypes.bool.isRequired, + expanded: PropTypes.bool.isRequired, video: PropTypes.string.isRequired }; @@ -256,6 +288,7 @@ const Cards = props => { onShowAll, onNextStep, onPrevStep, + showVideos, step, expanded, ...posProps @@ -298,6 +331,7 @@ const Cards = props => { > <Draggable bounds="parent" + cancel="#video-div" // disable dragging on video div position={{x: x, y: y}} onDrag={onDrag} onStart={onStartDrag} @@ -323,10 +357,18 @@ const Cards = props => { /> ) : ( steps[step].video ? ( - <VideoStep - dragging={dragging} - video={translateVideo(steps[step].video, locale)} - /> + showVideos ? ( + <VideoStep + dragging={dragging} + expanded={expanded} + video={translateVideo(steps[step].video, locale)} + /> + ) : ( // Else show the deck image and title + <ImageStep + image={content[activeDeckId].img} + title={content[activeDeckId].name} + /> + ) ) : ( <ImageStep image={translateImage(steps[step].image, locale)} @@ -376,9 +418,19 @@ Cards.propTypes = { onShowAll: PropTypes.func, onShrinkExpandCards: PropTypes.func.isRequired, onStartDrag: PropTypes.func, + showVideos: PropTypes.bool, step: PropTypes.number.isRequired, x: PropTypes.number, y: PropTypes.number }; -export default Cards; +Cards.defaultProps = { + showVideos: true +}; + +export { + Cards as default, + // Others exported for testability + ImageStep, + VideoStep +}; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 2e94229f282e21adc1ecd6e181123cf2b8d3866b..dfcc7d8ddc9109788665cbf76eb776aefd9a9e9c 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -129,11 +129,7 @@ const GUIComponent = props => { }; if (isRendererSupported === null) { - if (vm.renderer) { - isRendererSupported = true; - } else { - isRendererSupported = Renderer.isSupported(); - } + isRendererSupported = Renderer.isSupported(); } return (<MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => { diff --git a/src/components/library/library.css b/src/components/library/library.css index bb5d70c67d05c08fa5a7f875360673c9cbd8e29a..51287daa7216ab95a06b13c150606c7702770cb3 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -12,6 +12,7 @@ overflow-y: auto; height: auto; padding: 0.5rem; + height: calc(100% - $library-header-height); } .library-scroll-grid.withFilterBar { @@ -59,3 +60,11 @@ height: $library-filter-bar-height; overflow: hidden; } + +.spinner-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 60f65a06d5760df690a9bbea8788b426e1629a23..9ed7f090791e7bc61b1212c9d4b6599ecf71e976 100644 --- a/src/components/library/library.jsx +++ b/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 Spinner from '../spinner/spinner.jsx'; import styles from './library.css'; @@ -44,9 +45,16 @@ class LibraryComponent extends React.Component { this.state = { selectedItem: null, filterQuery: '', - selectedTag: ALL_TAG.tag + selectedTag: ALL_TAG.tag, + loaded: false }; } + componentDidMount () { + // Allow the spinner to display before loading the content + setTimeout(() => { + this.setState({loaded: true}); + }); + } componentDidUpdate (prevProps, prevState) { if (prevState.filterQuery !== this.state.filterQuery || prevState.selectedTag !== this.state.selectedTag) { @@ -162,7 +170,7 @@ class LibraryComponent extends React.Component { })} ref={this.setFilteredDataRef} > - {this.getFilteredData().map((dataItem, index) => ( + {this.state.loaded ? this.getFilteredData().map((dataItem, index) => ( <LibraryItem bluetoothRequired={dataItem.bluetoothRequired} collaborator={dataItem.collaborator} @@ -183,7 +191,14 @@ class LibraryComponent extends React.Component { onMouseLeave={this.handleMouseLeave} onSelect={this.handleSelect} /> - ))} + )) : ( + <div className={styles.spinnerWrapper}> + <Spinner + large + level="primary" + /> + </div> + )} </div> </Modal> ); diff --git a/src/components/spinner/spinner.css b/src/components/spinner/spinner.css index 3abc8ffbbea117d723b8abe36a98ac5cdb36aa9b..16d323f5fa5a113da1fff9f484e2cf44453f6c8a 100644 --- a/src/components/spinner/spinner.css +++ b/src/components/spinner/spinner.css @@ -39,6 +39,16 @@ height: .5rem; } +.large { + width: 2.5rem; + height: 2.5rem; +} + +.large::before, .large::after { + width: 2.5rem; + height: 2.5rem; +} + @keyframes spin { 0% { transform: rotate(0deg); @@ -71,3 +81,10 @@ .spinner.info::after { border-top-color: $ui-white; } + +.spinner.primary { + border-color: $motion-transparent; +} +.spinner.primary::after { + border-top-color: $motion-primary; +} diff --git a/src/components/spinner/spinner.jsx b/src/components/spinner/spinner.jsx index f25e3ebb18771e9c94a1010930f9d2a65337e92e..6fc23ed80a85c286b6c8d7ffb295bc5c1304c9ed 100644 --- a/src/components/spinner/spinner.jsx +++ b/src/components/spinner/spinner.jsx @@ -8,7 +8,8 @@ const SpinnerComponent = function (props) { const { className, level, - small + small, + large } = props; return ( <div @@ -16,18 +17,24 @@ const SpinnerComponent = function (props) { className, styles.spinner, styles[level], - {[styles.small]: small} + { + [styles.small]: small, + [styles.large]: large + } )} /> ); }; SpinnerComponent.propTypes = { className: PropTypes.string, + large: PropTypes.bool, level: PropTypes.string, small: PropTypes.bool }; SpinnerComponent.defaultProps = { className: '', - level: 'info' + large: false, + level: 'info', + small: false }; export default SpinnerComponent; diff --git a/src/containers/backdrop-library.jsx b/src/containers/backdrop-library.jsx index 5c2a315dcf9df8fdd70a42c266ffa85c4dded334..9d6b559c56c15ce1843a84964e286a7cecf9f5c8 100644 --- a/src/containers/backdrop-library.jsx +++ b/src/containers/backdrop-library.jsx @@ -2,7 +2,6 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import {connect} from 'react-redux'; import VM from 'scratch-vm'; import backdropLibraryContent from '../lib/libraries/backdrops.json'; @@ -33,7 +32,7 @@ class BackdropLibrary extends React.Component { bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - this.props.vm.setEditingTarget(this.props.stageID); + // Do not switch to stage, just add the backdrop this.props.vm.addBackdrop(item.md5, vmBackdrop); } render () { @@ -53,17 +52,7 @@ class BackdropLibrary extends React.Component { BackdropLibrary.propTypes = { intl: intlShape.isRequired, onRequestClose: PropTypes.func, - stageID: PropTypes.string.isRequired, vm: PropTypes.instanceOf(VM).isRequired }; -const mapStateToProps = state => ({ - stageID: state.scratchGui.targets.stage.id -}); - -const mapDispatchToProps = () => ({}); - -export default injectIntl(connect( - mapStateToProps, - mapDispatchToProps -)(BackdropLibrary)); +export default injectIntl(BackdropLibrary); diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 6ec5771c65e77606da95ce4a7baa06e4d5ca27a0..fb01ce15d5dd321908d8fc38960e7b3ff1d65244 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -31,10 +31,6 @@ import { SOUNDS_TAB_INDEX } from '../reducers/editor-tab'; -const UNINITIALIZED_TOOLBOX_XML = `<xml style="display: none"> - <category name="%{BKY_CATEGORY_MOTION}" id="motion" colour="#4C97FF" secondaryColour="#3373CC"></category> -</xml>`; - const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; object[property] = function () { @@ -88,19 +84,16 @@ class Blocks extends React.Component { }; this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); this.toolboxUpdateQueue = []; - this.initializedWorkspace = false; } componentDidMount () { this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures; this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); - const toolboxXML = UNINITIALIZED_TOOLBOX_XML; - const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, this.props.options, - {rtl: this.props.isRtl, toolbox: toolboxXML} + {rtl: this.props.isRtl, toolbox: this.props.toolboxXML} ); this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig); @@ -122,7 +115,7 @@ class Blocks extends React.Component { // Store the xml of the toolbox that is actually rendered. // This is used in componentDidUpdate instead of prevProps, because // the xml can change while e.g. on the costumes tab. - this._renderedToolboxXML = toolboxXML; + this._renderedToolboxXML = this.props.toolboxXML; // we actually never want the workspace to enable "refresh toolbox" - this basically re-renders the // entire toolbox every time we reset the workspace. We call updateToolbox as a part of @@ -196,19 +189,13 @@ class Blocks extends React.Component { componentWillUnmount () { this.detachVM(); this.workspace.dispose(); - if (this.toolboxUpdateTimeout) this.toolboxUpdateTimeout.cancel(); + clearTimeout(this.toolboxUpdateTimeout); } requestToolboxUpdate () { - if (this.toolboxUpdateTimeout) this.toolboxUpdateTimeout.cancel(); - let running = true; - this.toolboxUpdateTimeout = Promise.resolve().then(() => { - if (running) { - this.updateToolbox(); - } - }); - this.toolboxUpdateTimeout.cancel = () => { - running = false; - }; + clearTimeout(this.toolboxUpdateTimeout); + this.toolboxUpdateTimeout = setTimeout(() => { + this.updateToolbox(); + }, 0); } setLocale () { this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); @@ -228,15 +215,8 @@ class Blocks extends React.Component { const categoryId = this.workspace.toolbox_.getSelectedCategoryId(); const offset = this.workspace.toolbox_.getCategoryScrollOffset(); - - let toolboxXML = this.props.toolboxXML; - if (!this.initializedWorkspace) { - toolboxXML = UNINITIALIZED_TOOLBOX_XML; - } - if (this._renderedToolboxXML !== toolboxXML) { - this.workspace.updateToolbox(toolboxXML); - this._renderedToolboxXML = toolboxXML; - } + this.workspace.updateToolbox(this.props.toolboxXML); + this._renderedToolboxXML = this.props.toolboxXML; // In order to catch any changes that mutate the toolbox during "normal runtime" // (variable changes/etc), re-enable toolbox refresh. @@ -372,7 +352,6 @@ class Blocks extends React.Component { // When we change sprites, update the toolbox to have the new sprite's blocks const toolboxXML = this.getToolboxXML(); if (toolboxXML) { - this.initializedWorkspace = true; this.props.updateToolboxState(toolboxXML); } diff --git a/src/containers/cards.jsx b/src/containers/cards.jsx index 751d523f7153e1711cda00894a12b7dd0092a2ca..9777a5a9509d540b92e2f95e4f51c68889361299 100644 --- a/src/containers/cards.jsx +++ b/src/containers/cards.jsx @@ -19,6 +19,7 @@ import { import CardsComponent from '../components/cards/cards.jsx'; import {loadImageData} from '../lib/libraries/decks/translate-image.js'; +import {notScratchDesktop} from '../lib/isScratchDesktop'; class Cards extends React.Component { componentDidMount () { @@ -52,7 +53,8 @@ const mapStateToProps = state => ({ y: state.scratchGui.cards.y, isRtl: state.locales.isRtl, locale: state.locales.locale, - dragging: state.scratchGui.cards.dragging + dragging: state.scratchGui.cards.dragging, + showVideos: notScratchDesktop() }); const mapDispatchToProps = dispatch => ({ diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 2a4ecdea6aa257b2fffd0e3b54de15ddbcfdd487..173f1218a6e3c12bfb3cf3fa812796b5f6ee5d5d 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -35,13 +35,10 @@ import storage from '../lib/storage'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; import vmManagerHOC from '../lib/vm-manager-hoc.jsx'; import cloudManagerHOC from '../lib/cloud-manager-hoc.jsx'; -import Loader from '../components/loader/loader.jsx'; -// import GUIComponent from '../components/gui/gui.jsx'; +import GUIComponent from '../components/gui/gui.jsx'; import {setIsScratchDesktop} from '../lib/isScratchDesktop.js'; -import VideoProvider from '../lib/video/video-provider'; - const messages = defineMessages({ defaultProjectTitle: { id: 'gui.gui.defaultProjectTitle', @@ -55,22 +52,6 @@ class GUI extends React.Component { setIsScratchDesktop(this.props.isScratchDesktop); this.setReduxTitle(this.props.projectTitle); this.props.onStorageInit(storage); - - // Use setTimeout. Do not use requestAnimationFrame or a resolved - // Promise. We want this work delayed until after the data request is - // made. - setTimeout(this.ensureRenderer.bind(this)); - - // Once the GUI component has been rendered, always render GUI and do - // not revert back to a Loader in this component. - // - // This makes GUI container not a pure component. We don't want to use - // state for this. That would possibly cause a full second render of GUI - // after the first one. - const {fontsLoaded, fetchingProject, isLoading} = this.props; - this.isAfterGUI = this.isAfterGUI || ( - fontsLoaded && !fetchingProject && !isLoading - ); } componentDidUpdate (prevProps) { if (this.props.projectId !== prevProps.projectId && this.props.projectId !== null) { @@ -84,17 +65,6 @@ class GUI extends React.Component { // At this time the project view in www doesn't need to know when a project is unloaded this.props.onProjectLoaded(); } - - // Once the GUI component has been rendered, always render GUI and do - // not revert back to a Loader in this component. - // - // This makes GUI container not a pure component. We don't want to use - // state for this. That would possibly cause a full second render of GUI - // after the first one. - const {fontsLoaded, fetchingProject, isLoading} = this.props; - this.isAfterGUI = this.isAfterGUI || ( - fontsLoaded && !fetchingProject && !isLoading - ); } setReduxTitle (newTitle) { if (newTitle === null || typeof newTitle === 'undefined') { @@ -105,36 +75,6 @@ class GUI extends React.Component { this.props.onUpdateReduxProjectTitle(newTitle); } } - ensureRenderer () { - if (this.props.vm.renderer) { - return; - } - - // Wait to load svg-renderer and render after the data request. This - // way the data request is made earlier. - const Renderer = require('scratch-render'); - const { - SVGRenderer: V2SVGAdapter, - BitmapAdapter: V2BitmapAdapter - } = require('scratch-svg-renderer'); - - const vm = this.props.vm; - this.canvas = document.createElement('canvas'); - this.renderer = new Renderer(this.canvas); - vm.attachRenderer(this.renderer); - - vm.attachV2SVGAdapter(new V2SVGAdapter()); - vm.attachV2BitmapAdapter(new V2BitmapAdapter()); - - // Only attach a video provider once because it is stateful - vm.setVideoProvider(new VideoProvider()); - - // Calling draw a single time before any project is loaded just - // makes the canvas white instead of solid black–needed because it - // is not possible to use CSS to style the canvas to have a - // different default color - vm.renderer.draw(); - } render () { if (this.props.isError) { throw new Error( @@ -145,7 +85,6 @@ class GUI extends React.Component { assetHost, cloudHost, error, - fontsLoaded, isError, isScratchDesktop, isShowingProject, @@ -163,16 +102,6 @@ class GUI extends React.Component { loadingStateVisible, ...componentProps } = this.props; - - if (!this.isAfterGUI && ( - !fontsLoaded || fetchingProject || isLoading - )) { - // Make sure a renderer exists. - if (fontsLoaded && !fetchingProject) this.ensureRenderer(); - return <Loader />; - } - - const GUIComponent = require('../components/gui/gui.jsx').default; return ( <GUIComponent loading={fetchingProject || isLoading || loadingStateVisible} @@ -190,7 +119,6 @@ GUI.propTypes = { cloudHost: PropTypes.string, error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), fetchingProject: PropTypes.bool, - fontsLoaded: PropTypes.bool, intl: intlShape, isError: PropTypes.bool, isLoading: PropTypes.bool, @@ -229,7 +157,6 @@ const mapStateToProps = state => { costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, error: state.scratchGui.projectState.error, - fontsLoaded: state.scratchGui.fontsLoaded, isError: getIsError(loadingState), isFullScreen: state.scratchGui.mode.isFullScreen, isPlayerOnly: state.scratchGui.mode.isPlayerOnly, diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index 4babbc014233a8bc817e2095a2fb3cd6f9463697..d091c9587438461d3864333c6b074fc5b9eebf4d 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -50,7 +50,7 @@ class StageSelector extends React.Component { 'setFileInput' ]); } - addBackdropFromLibraryItem (item) { + addBackdropFromLibraryItem (item, shouldActivateTab = true) { const vmBackdrop = { name: item.name, md5: item.md5, @@ -59,25 +59,30 @@ class StageSelector extends React.Component { bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - this.handleNewBackdrop(vmBackdrop); + this.handleNewBackdrop(vmBackdrop, shouldActivateTab); } handleClick () { this.props.onSelect(this.props.id); } - handleNewBackdrop (backdrops_) { + handleNewBackdrop (backdrops_, shouldActivateTab = true) { const backdrops = Array.isArray(backdrops_) ? backdrops_ : [backdrops_]; return Promise.all(backdrops.map(backdrop => this.props.vm.addBackdrop(backdrop.md5, backdrop) - )).then(() => - this.props.onActivateTab(COSTUMES_TAB_INDEX) - ); + )).then(() => { + if (shouldActivateTab) { + return this.props.onActivateTab(COSTUMES_TAB_INDEX); + } + }); } - handleSurpriseBackdrop () { + 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)]; - this.addBackdropFromLibraryItem(item); + this.addBackdropFromLibraryItem(item, false); } - handleEmptyBackdrop () { + handleEmptyBackdrop (e) { + e.stopPropagation(); // Prevent click from falling through to stage selector, select it manually below + this.props.vm.setEditingTarget(this.props.id); this.handleNewBackdrop(emptyCostume(this.props.intl.formatMessage(sharedMessages.backdrop, {index: 1}))); } handleBackdropUpload (e) { @@ -85,6 +90,7 @@ class StageSelector extends React.Component { this.props.onShowImporting(); handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => { costumeUpload(buffer, fileType, storage, vmCostumes => { + this.props.vm.setEditingTarget(this.props.id); vmCostumes.forEach((costume, i) => { costume.name = `${fileName}${i ? i + 1 : ''}`; }); @@ -96,7 +102,8 @@ class StageSelector extends React.Component { }, this.props.onCloseImporting); }, this.props.onCloseImporting); } - handleFileUploadClick () { + handleFileUploadClick (e) { + e.stopPropagation(); // Prevent click from selecting the stage, that is handled manually in backdrop upload this.fileInput.click(); } handleMouseEnter () { diff --git a/src/containers/tips-library.jsx b/src/containers/tips-library.jsx index db252e707d8c476c82a34a0208d8ee66f088061f..5ed0beb9ba01ce8807bad443d24cf96bc7544755 100644 --- a/src/containers/tips-library.jsx +++ b/src/containers/tips-library.jsx @@ -62,10 +62,16 @@ class TipsLibrary extends React.PureComponent { } render () { const decksLibraryThumbnailData = Object.keys(decksLibraryContent) - .filter(id => + .filter(id => { + if (notScratchDesktop()) return true; // Do not filter anything in online editor + const deck = decksLibraryContent[id]; // Scratch Desktop doesn't want tutorials with `requiredProjectId` - notScratchDesktop() || !decksLibraryContent[id].hasOwnProperty('requiredProjectId') - ) + if (deck.hasOwnProperty('requiredProjectId')) return false; + // Scratch Desktop should not load tutorials that are _only_ videos + if (deck.steps.filter(s => s.title).length === 0) return false; + // Allow any other tutorials + return true; + }) .map(id => ({ rawURL: decksLibraryContent[id].img, id: id, diff --git a/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg b/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg deleted file mode 100755 index cf8e01588d5d0db6eea9711456286ec72759a286..0000000000000000000000000000000000000000 Binary files a/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg and /dev/null differ diff --git a/src/lib/default-project/3696356a03a8d938318876a593572843.svg b/src/lib/default-project/3696356a03a8d938318876a593572843.svg deleted file mode 100755 index 657f5b598fca1118f7a8e7373f849910011df88d..0000000000000000000000000000000000000000 Binary files a/src/lib/default-project/3696356a03a8d938318876a593572843.svg and /dev/null differ diff --git a/src/lib/default-project/b7853f557e4426412e64bb3da6531a99.svg b/src/lib/default-project/b7853f557e4426412e64bb3da6531a99.svg new file mode 100644 index 0000000000000000000000000000000000000000..a537afb3aa548df6e3565cd16b522c88e92b8d6e Binary files /dev/null and b/src/lib/default-project/b7853f557e4426412e64bb3da6531a99.svg differ diff --git a/src/lib/default-project/e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg b/src/lib/default-project/e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg new file mode 100644 index 0000000000000000000000000000000000000000..d49c68211a4fc5614387c261a8dec0a9ffb6304e Binary files /dev/null and b/src/lib/default-project/e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg differ diff --git a/src/lib/default-project/index.js b/src/lib/default-project/index.js index 92f01c908c1b3dfd05a71636fc71cd0eddfb3181..08dbdb32e60fafb136057e413a40c85b52f6ed1a 100644 --- a/src/lib/default-project/index.js +++ b/src/lib/default-project/index.js @@ -4,8 +4,8 @@ import projectData from './project-data'; import popWav from '!arraybuffer-loader!./83a9787d4cb6f3b7632b4ddfebf74367.wav'; import meowWav from '!arraybuffer-loader!./83c36d806dc92327b9e7049a565c6bff.wav'; import backdrop from '!raw-loader!./cd21514d0531fdffb22204e0ec5ed84a.svg'; -import costume1 from '!raw-loader!./09dc888b0b7df19f70d81588ae73420e.svg'; -import costume2 from '!raw-loader!./3696356a03a8d938318876a593572843.svg'; +import costume1 from '!raw-loader!./b7853f557e4426412e64bb3da6531a99.svg'; +import costume2 from '!raw-loader!./e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg'; /* eslint-enable import/no-unresolved */ const defaultProject = translator => { @@ -40,12 +40,12 @@ const defaultProject = translator => { dataFormat: 'SVG', data: encoder.encode(backdrop) }, { - id: '09dc888b0b7df19f70d81588ae73420e', + id: 'b7853f557e4426412e64bb3da6531a99', assetType: 'ImageVector', dataFormat: 'SVG', data: encoder.encode(costume1) }, { - id: '3696356a03a8d938318876a593572843', + id: 'e6ddc55a6ddd9cc9d84fe0b4c21e016f', assetType: 'ImageVector', dataFormat: 'SVG', data: encoder.encode(costume2) diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index ddafe8704947d664768d89e071c17c481999199a..f10f0800a557413590e4b181bc862faf8b57d5ce 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -26,8 +26,7 @@ class SeleniumHelper { 'getSauceDriver', 'getLogs', 'loadUri', - 'rightClickText', - 'waitUntilGone' + 'rightClickText' ]); } @@ -85,7 +84,11 @@ class SeleniumHelper { } findByXpath (xpath, timeoutMessage = `findByXpath timed out for path: ${xpath}`) { - return this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage); + return this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage) + .then(el => ( + this.driver.wait(el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS, `${xpath} is not visible`) + .then(() => el) + )); } findByText (text, scope) { @@ -125,10 +128,6 @@ class SeleniumHelper { return this.clickXpath(`//button//*[contains(text(), '${text}')]`); } - waitUntilGone (element, timeoutMessage = 'waitUntilGone timed out') { - return this.driver.wait(until.stalenessOf(element), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage); - } - getLogs (whitelist) { if (!whitelist) { // Default whitelist diff --git a/test/integration/backdrops.test.js b/test/integration/backdrops.test.js index 3d2f64afa29c56a4e9830a9a12827fe15c78653f..95a268b76c3ee4016013c110d012f555be62717f 100644 --- a/test/integration/backdrops.test.js +++ b/test/integration/backdrops.test.js @@ -25,7 +25,7 @@ describe('Working with backdrops', () => { await driver.quit(); }); - test('Adding a backdrop from the library', async () => { + test('Adding a backdrop from the library should not switch to stage', async () => { await loadUri(uri); // Start on the sounds tab of sprite1 to test switching behavior @@ -37,12 +37,11 @@ describe('Working with backdrops', () => { await el.sendKeys('blue'); await clickText('Blue Sky'); // Adds the backdrop - // Make sure the stage is selected and the sound tab remains selected. - // This is different from Scratch2 which selected backdrop tab automatically - // See issue #3500 - await clickText('pop', scope.soundsTab); + // Make sure the sprite is still selected, and that the tab has not changed + await clickText('Meow', scope.soundsTab); // Make sure the backdrop was actually added by going to the backdrops tab + await clickXpath('//span[text()="Stage"]'); await clickText('Backdrops'); await clickText('Blue Sky', scope.costumesTab); @@ -50,7 +49,48 @@ describe('Working with backdrops', () => { await expect(logs).toEqual([]); }); - test('Adding multiple backdrops at the same time', async () => { + test('Adding backdrop via paint should switch to stage', async () => { + await loadUri(uri); + + const buttonXpath = '//button[@aria-label="Choose a Backdrop"]'; + const paintXpath = `${buttonXpath}/following-sibling::div//button[@aria-label="Paint"]`; + + const el = await findByXpath(buttonXpath); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + await clickXpath(paintXpath); + + // Stage should become selected and costume tab activated + await findByText('backdrop2', scope.costumesTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + test('Adding backdrop via surprise should not switch to stage', async () => { + await loadUri(uri); + + // Start on the sounds tab of sprite1 to test switching behavior + await clickText('Sounds'); + + const buttonXpath = '//button[@aria-label="Choose a Backdrop"]'; + const surpriseXpath = `${buttonXpath}/following-sibling::div//button[@aria-label="Surprise"]`; + + const el = await findByXpath(buttonXpath); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + await clickXpath(surpriseXpath); + + // Make sure the sprite is still selected, and that the tab has not changed + await clickText('Meow', scope.soundsTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + test('Adding multiple backdrops from file should switch to stage', async () => { const files = [ path.resolve(__dirname, '../fixtures/gh-3582-png.png'), path.resolve(__dirname, '../fixtures/100-100.svg') @@ -67,7 +107,7 @@ describe('Working with backdrops', () => { const input = await findByXpath(fileXpath); await input.sendKeys(files.join('\n')); - await clickXpath('//span[text()="Stage"]'); + // Should have been switched to stage/costume tab already await findByText('gh-3582-png', scope.costumesTab); await findByText('100-100', scope.costumesTab); diff --git a/test/integration/examples.test.js b/test/integration/examples.test.js index aa867b0414503bbb5ab1460c24cfb6c8633aea75..3554151db07dc3e2b7b21b4e0825747e845bffff 100644 --- a/test/integration/examples.test.js +++ b/test/integration/examples.test.js @@ -4,15 +4,13 @@ import path from 'path'; import SeleniumHelper from '../helpers/selenium-helper'; const { - findByText, clickButton, clickText, clickXpath, findByXpath, getDriver, getLogs, - loadUri, - waitUntilGone + loadUri } = new SeleniumHelper(); let driver; @@ -28,10 +26,9 @@ describe('player example', () => { await driver.quit(); }); - test('Load a project by ID', async () => { + test.skip('Player: load a project by ID', async () => { const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Go"]'); await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath('//img[@title="Stop"]'); @@ -59,7 +56,7 @@ describe('blocks example', () => { await driver.quit(); }); - test('Load a project by ID', async () => { + test.skip('Blocks: load a project by ID', async () => { const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); await new Promise(resolve => setTimeout(resolve, 2000)); diff --git a/test/integration/menu-bar.test.js b/test/integration/menu-bar.test.js index 7c653a9079d9f6f4df40c8d511de8df8ebaa3962..3f6457e5208ae171685e62f70327a3f3463a5f4c 100644 --- a/test/integration/menu-bar.test.js +++ b/test/integration/menu-bar.test.js @@ -9,8 +9,7 @@ const { getDriver, loadUri, rightClickText, - scope, - waitUntilGone + scope } = new SeleniumHelper(); const uri = path.resolve(__dirname, '../../build/index.html'); @@ -78,7 +77,6 @@ describe('Menu bar settings', () => { await clickText('File'); const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]'); await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3')); - await waitUntilGone(findByText('Loading')); // No replace alert since no changes were made await findByText('project1-sprite'); }); @@ -95,7 +93,6 @@ describe('Menu bar settings', () => { await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3')); await driver.switchTo().alert() .accept(); - await waitUntilGone(findByText('Loading')); await findByText('project1-sprite'); }); }); diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js index e0c5e5d1f9dc27392780ae5a345fd6b157a9fb14..b11a5bf594d6bd35bf68202bce1c9b3bf0a34cd0 100644 --- a/test/integration/project-loading.test.js +++ b/test/integration/project-loading.test.js @@ -9,8 +9,7 @@ const { getDriver, getLogs, loadUri, - scope, - waitUntilGone + scope } = new SeleniumHelper(); const uri = path.resolve(__dirname, '../../build/index.html'); @@ -33,13 +32,14 @@ describe('Loading scratch gui', () => { await clickText('Oops! Something went wrong.'); }); - test('Load a project by ID directly through url', async () => { + // skipping because it relies on network speed, and tests a method + // of loading projects that we are not actively using anymore + test.skip('Load a project by ID directly through url', async () => { await driver.quit(); // Reset driver to test hitting # url directly driver = getDriver(); const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Go"]'); await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath('//img[@title="Stop"]'); @@ -47,7 +47,9 @@ describe('Loading scratch gui', () => { await expect(logs).toEqual([]); }); - test('Load a project by ID (fullscreen)', async () => { + // skipping because it relies on network speed, and tests a method + // of loading projects that we are not actively using anymore + test.skip('Load a project by ID (fullscreen)', async () => { await driver.quit(); // Reset driver to test hitting # url directly driver = getDriver(); @@ -60,7 +62,6 @@ describe('Loading scratch gui', () => { .setSize(1920, 1080); const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Full Screen Control"]'); await new Promise(resolve => setTimeout(resolve, 500)); await clickXpath('//img[@title="Go"]'); @@ -77,7 +78,6 @@ describe('Loading scratch gui', () => { test('Creating new project resets active tab to Code tab', async () => { await loadUri(uri); - await new Promise(resolve => setTimeout(resolve, 2000)); await findByXpath('//*[span[text()="Costumes"]]'); await clickText('Costumes'); await clickXpath( @@ -85,13 +85,12 @@ describe('Loading scratch gui', () => { 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]' ); await clickXpath('//li[span[text()="New"]]'); - await findByXpath('//*[div[@class="scratchCategoryMenu"]]'); + await findByXpath('//div[@class="scratchCategoryMenu"]'); await clickText('Operators', scope.blocksTab); }); test('Not logged in->made no changes to project->create new project should not show alert', async () => { await loadUri(uri); - await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath( '//div[contains(@class, "menu-bar_menu-bar-item") and ' + 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]' @@ -103,10 +102,10 @@ describe('Loading scratch gui', () => { test('Not logged in->made a change to project->create new project should show alert', async () => { await loadUri(uri); - await new Promise(resolve => setTimeout(resolve, 2000)); await clickText('Sounds'); await clickXpath('//button[@aria-label="Choose a Sound"]'); await clickText('A Bass', scope.modal); // Should close the modal + await findByText('1.28'); // length of A Bass sound await clickXpath( '//div[contains(@class, "menu-bar_menu-bar-item") and ' + 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]' diff --git a/test/unit/components/cards.test.jsx b/test/unit/components/cards.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..617891e020149bb88f4c9446babc17f2118f9caa --- /dev/null +++ b/test/unit/components/cards.test.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; + +// Mock this utility because it uses dynamic imports that do not work with jest +jest.mock('../../../src/lib/libraries/decks/translate-image.js', () => {}); + +import Cards, {ImageStep, VideoStep} from '../../../src/components/cards/cards.jsx'; + +describe('Cards component', () => { + const defaultProps = () => ({ + activeDeckId: 'id1', + content: { + id1: { + name: 'id1 - name', + img: 'id1 - img', + steps: [{video: 'videoUrl'}] + } + }, + dragging: false, + expanded: true, + isRtl: false, + locale: 'en', + onActivateDeckFactory: jest.fn(), + onCloseCards: jest.fn(), + onDrag: jest.fn(), + onEndDrag: jest.fn(), + onNextStep: jest.fn(), + onPrevStep: jest.fn(), + onShowAll: jest.fn(), + onShrinkExpandCards: jest.fn(), + onStartDrag: jest.fn(), + showVideos: true, + step: 0, + x: 0, + y: 0 + }); + + test('showVideos=true shows the video step', () => { + const component = mountWithIntl( + <Cards + {...defaultProps()} + showVideos + /> + ); + expect(component.find(ImageStep).exists()).toEqual(false); + expect(component.find(VideoStep).exists()).toEqual(true); + }); + + test('showVideos=false shows the title image/name instead of video step', () => { + const component = mountWithIntl( + <Cards + {...defaultProps()} + showVideos={false} + /> + ); + expect(component.find(VideoStep).exists()).toEqual(false); + + const imageStep = component.find(ImageStep); + expect(imageStep.props().image).toEqual('id1 - img'); + expect(imageStep.props().title).toEqual('id1 - name'); + }); +}); diff --git a/test/unit/util/default-project.test.js b/test/unit/util/default-project.test.js new file mode 100644 index 0000000000000000000000000000000000000000..726519c4160211ffdbeff95669f147c76c31a98b --- /dev/null +++ b/test/unit/util/default-project.test.js @@ -0,0 +1,21 @@ +import defaultProjectGenerator from '../../../src/lib/default-project/index.js'; + +describe('defaultProject', () => { + // This test ensures that the assets referenced in the default project JSON + // do not get out of sync with the raw assets that are included alongside. + // see https://github.com/LLK/scratch-gui/issues/4844 + test('assets referenced by the project are included', () => { + const translatorFn = () => ''; + const defaultProject = defaultProjectGenerator(translatorFn); + const includedAssetIds = defaultProject.map(obj => obj.id); + const projectData = JSON.parse(defaultProject[0].data); + projectData.targets.forEach(target => { + target.costumes.forEach(costume => { + expect(includedAssetIds.includes(costume.assetId)).toBe(true); + }); + target.sounds.forEach(sound => { + expect(includedAssetIds.includes(sound.assetId)).toBe(true); + }); + }); + }); +});