diff --git a/.travis.yml b/.travis.yml index 36e41544b0a54af7ff0ac476ca6426ee3057cedc..7e3b4f23a6f05a9b7ff3c3d246f57ce0df836a38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -58,11 +58,6 @@ deploy: condition: $TRAVIS_EVENT_TYPE != cron skip_cleanup: true script: npm run deploy -- -x -e $TRAVIS_BRANCH -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git -- provider: script - on: - all_branches: true - condition: $TRAVIS_EVENT_TYPE != cron - script: npm run prune -- https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git - provider: script on: branch: develop diff --git a/package.json b/package.json index b6fafe6186b3be993f34224e2c762e97f9d41be2..f83f826fe736b0b758daa025e018a9fda572dfd2 100644 --- a/package.json +++ b/package.json @@ -104,13 +104,13 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.20190114210212", - "scratch-blocks": "0.1.0-prerelease.1549376808", - "scratch-l10n": "3.1.20190206143031", + "scratch-blocks": "0.1.0-prerelease.1549643185", + "scratch-l10n": "3.1.20190207224638", "scratch-paint": "0.2.0-prerelease.20190114205252", - "scratch-render": "0.1.0-prerelease.20190128154859", + "scratch-render": "0.1.0-prerelease.20190208165820", "scratch-storage": "1.2.2", "scratch-svg-renderer": "0.2.0-prerelease.20190125192231", - "scratch-vm": "0.2.0-prerelease.20190205221329", + "scratch-vm": "0.2.0-prerelease.20190207224121", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx index 36cf5d4449f7ddd1815cb5388d3784915a709a3e..f9e50f3ff667e02ff78cc5d345e761993e9b8709 100644 --- a/src/components/action-menu/action-menu.jsx +++ b/src/components/action-menu/action-menu.jsx @@ -142,7 +142,7 @@ class ActionMenu extends React.Component { <div className={styles.moreButtonsOuter}> <div className={styles.moreButtons}> {(moreButtons || []).map(({img, title, onClick: handleClick, - fileAccept, fileChange, fileInput}, keyId) => { + fileAccept, fileChange, fileInput, fileMultiple}, keyId) => { const isComingSoon = !handleClick; const hasFileInput = fileInput; const tooltipId = `${this.mainTooltipId}-${title}`; @@ -166,6 +166,7 @@ class ActionMenu extends React.Component { <input accept={fileAccept} className={styles.fileInput} + multiple={fileMultiple} ref={fileInput} type="file" onChange={fileChange} @@ -198,7 +199,8 @@ ActionMenu.propTypes = { onClick: PropTypes.func, // Optional, "coming soon" if no callback provided fileAccept: PropTypes.string, // Optional, only for file upload fileChange: PropTypes.func, // Optional, only for file upload - fileInput: PropTypes.func // Optional, only for file upload + fileInput: PropTypes.func, // Optional, only for file upload + fileMultiple: PropTypes.bool // Optional, only for file upload })), onClick: PropTypes.func.isRequired, title: PropTypes.node.isRequired, diff --git a/src/components/loader/loader.jsx b/src/components/loader/loader.jsx index 6f4b9658a9ab90a2617b3b3f140010781a2c1ba9..c51621fc41ad8d52596284ace87bff81be1af8e2 100644 --- a/src/components/loader/loader.jsx +++ b/src/components/loader/loader.jsx @@ -120,15 +120,13 @@ class LoaderComponent extends React.Component { constructor (props) { super(props); this.state = { - messageNumber: 0 + messageNumber: this.chooseRandomMessage() }; } componentDidMount () { - this.chooseRandomMessage(); - // Start an interval to choose a new message every 5 seconds this.intervalId = setInterval(() => { - this.chooseRandomMessage(); + this.setState({messageNumber: this.chooseRandomMessage()}); }, 5000); } componentWillUnmount () { @@ -145,7 +143,7 @@ class LoaderComponent extends React.Component { break; } } - this.setState({messageNumber}); + return messageNumber; } render () { return ( diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 115eaee955f25d6ccba82b86c7f8206cc0ee3719..d834f43b03bd44a23ce4fe6326543670439dbe27 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -122,7 +122,8 @@ const SpriteSelectorComponent = function (props) { onClick: onFileUploadClick, fileAccept: '.svg, .png, .jpg, .jpeg, .sprite2, .sprite3', fileChange: onSpriteUpload, - fileInput: spriteFileInput + fileInput: spriteFileInput, + fileMultiple: true }, { title: intl.formatMessage(messages.addSpriteFromSurprise), img: surpriseIcon, diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index 64a1948a82670324221c5743f0657e9215d89677..db89a67c5a961a5419d7bf5cb850bd75ae504813 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -104,7 +104,8 @@ const StageSelector = props => { onClick: onBackdropFileUploadClick, fileAccept: '.svg, .png, .jpg, .jpeg', // Bitmap coming soon fileChange: onBackdropFileUpload, - fileInput: fileInputRef + fileInput: fileInputRef, + fileMultiple: true }, { title: intl.formatMessage(messages.addBackdropFromSurprise), img: surpriseIcon, diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 83b27d4c3252cccd83c1f1567d73d486c4790b5d..3817c4f3c9fda0b4111194eaa95298bd5abe4285 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -13,7 +13,7 @@ import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import DragConstants from '../lib/drag-constants'; import {emptyCostume} from '../lib/empty-assets'; import sharedMessages from '../lib/shared-messages'; -import download from '../lib/download-url'; +import downloadBlob from '../lib/download-blob'; import { closeCameraCapture, @@ -154,7 +154,8 @@ class CostumeTab extends React.Component { } handleExportCostume (costumeIndex) { const item = this.props.vm.editingTarget.sprite.costumes[costumeIndex]; - download(`${item.name}.${item.asset.dataFormat}`, item.asset.encodeDataURI()); + const blob = new Blob([item.asset.data], {type: item.asset.assetType.contentType}); + downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob); } handleNewCostume (costume, fromCostumeLibrary) { if (fromCostumeLibrary) { @@ -292,7 +293,8 @@ class CostumeTab extends React.Component { onClick: this.handleFileUploadClick, fileAccept: '.svg, .png, .jpg, .jpeg', fileChange: this.handleCostumeUpload, - fileInput: this.setFileInput + fileInput: this.setFileInput, + fileMultiple: true }, { title: intl.formatMessage(messages.addSurpriseCostumeMsg), diff --git a/src/containers/monitor.jsx b/src/containers/monitor.jsx index 039fa060d616d2df878f75eff8140fcbdcb51ecb..b35a04bb9c2056b74f276d213ac45fa42a2a343f 100644 --- a/src/containers/monitor.jsx +++ b/src/containers/monitor.jsx @@ -8,7 +8,7 @@ import MonitorComponent, {monitorModes} from '../components/monitor/monitor.jsx' import {addMonitorRect, getInitialPosition, resizeMonitorRect, removeMonitorRect} from '../reducers/monitor-layout'; import {getVariable, setVariableValue} from '../lib/variable-utils'; import importCSV from '../lib/import-csv'; -import download from '../lib/download-url'; +import downloadBlob from '../lib/download-blob'; import {connect} from 'react-redux'; import {Map} from 'immutable'; @@ -156,7 +156,8 @@ class Monitor extends React.Component { const {vm, targetId, id: variableId} = this.props; const variable = getVariable(vm, targetId, variableId); const text = variable.value.join('\r\n'); - download(`${variable.name}.txt`, `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`); + const blob = new Blob([text], {type: 'text/plain;charset=utf-8'}); + downloadBlob(`${variable.name}.txt`, blob); } render () { const monitorProps = monitorAdapter(this.props); diff --git a/src/containers/sb3-downloader.jsx b/src/containers/sb3-downloader.jsx index 47df7bacaebe341abc571d3c9ca7e22e2d024e34..536934a270cfdbaace177fca5e4046a47b38f8ff 100644 --- a/src/containers/sb3-downloader.jsx +++ b/src/containers/sb3-downloader.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; import {projectTitleInitialState} from '../reducers/project-title'; - +import downloadBlob from '../lib/download-blob'; /** * Project saver component passes a downloadProject function to its child. * It expects this child to be a function with the signature @@ -26,25 +26,11 @@ class SB3Downloader extends React.Component { ]); } downloadProject () { - const downloadLink = document.createElement('a'); - document.body.appendChild(downloadLink); - this.props.saveProjectSb3().then(content => { if (this.props.onSaveFinished) { this.props.onSaveFinished(); } - // Use special ms version if available to get it working on Edge. - if (navigator.msSaveOrOpenBlob) { - navigator.msSaveOrOpenBlob(content, this.props.projectFilename); - return; - } - - const url = window.URL.createObjectURL(content); - downloadLink.href = url; - downloadLink.download = this.props.projectFilename; - downloadLink.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(downloadLink); + downloadBlob(this.props.projectFilename, content); }); } render () { diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 40e08dfb737321de558b278c011c4a2e9e980142..9db3289d96d9eeb61b30a6d5fbe60244b5ec61a1 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -21,7 +21,7 @@ import soundLibraryContent from '../lib/libraries/sounds.json'; import {handleFileUpload, soundUpload} from '../lib/file-uploader.js'; import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import DragConstants from '../lib/drag-constants'; -import download from '../lib/download-url'; +import downloadBlob from '../lib/download-blob'; import {connect} from 'react-redux'; @@ -90,8 +90,8 @@ class SoundTab extends React.Component { handleExportSound (soundIndex) { const item = this.props.vm.editingTarget.sprite.sounds[soundIndex]; - const soundDataURL = item.asset.encodeDataURI(); - download(`${item.name}.${item.asset.dataFormat}`, soundDataURL); + const blob = new Blob([item.asset.data], {type: item.asset.assetType.contentType}); + downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob); } handleDuplicateSound (soundIndex) { @@ -223,7 +223,8 @@ class SoundTab extends React.Component { onClick: this.handleFileUploadClick, fileAccept: '.wav, .mp3', fileChange: this.handleSoundUpload, - fileInput: this.setFileInput + fileInput: this.setFileInput, + fileMultiple: true }, { title: intl.formatMessage(messages.surpriseSound), img: surpriseIcon, diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 16017423c903b881176569fc9255d13daea02d77..25382b9054c43de3e007be12fae4202c954b8860 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -21,6 +21,7 @@ import {emptySprite} from '../lib/empty-assets'; import {highlightTarget} from '../reducers/targets'; import {fetchSprite, fetchCode} from '../lib/backpack-api'; import randomizeSpritePosition from '../lib/randomize-sprite-position'; +import downloadBlob from '../lib/download-blob'; class TargetPane extends React.Component { constructor (props) { @@ -94,20 +95,7 @@ class TargetPane extends React.Component { document.body.appendChild(saveLink); this.props.vm.exportSprite(id).then(content => { - const filename = `${spriteName}.sprite3`; - - // Use special ms version if available to get it working on Edge. - if (navigator.msSaveOrOpenBlob) { - navigator.msSaveOrOpenBlob(content, filename); - return; - } - - const url = window.URL.createObjectURL(content); - saveLink.href = url; - saveLink.download = filename; - saveLink.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(saveLink); + downloadBlob(`${spriteName}.sprite3`, content); }); } handleSelectSprite (id) { diff --git a/src/lib/blocks.js b/src/lib/blocks.js index eadf6d6fff6ec22e75f019178aef53e262bf4b9a..9ca3c6d7ed7b3a33cb83d65992f56f7e9f1cecfe 100644 --- a/src/lib/blocks.js +++ b/src/lib/blocks.js @@ -245,8 +245,15 @@ export default function (vm) { // The block was in the flyout so look up future block info there. lookupBlocks = vm.runtime.flyoutBlocks; } + const sort = function (options) { + options.sort((str1, str2) => str1.localeCompare(str2, [], { + sensitivity: 'base', + numeric: true + })); + }; // Get all the stage variables (no lists) so we can add them to menu when the stage is selected. const stageVariableOptions = vm.runtime.getTargetForStage().getAllVariableNamesInScopeByType(''); + sort(stageVariableOptions); const stageVariableMenuItems = stageVariableOptions.map(variable => [variable, variable]); if (sensingOfBlock.inputs.OBJECT.shadow !== sensingOfBlock.inputs.OBJECT.block) { // There's a block dropped on top of the menu. It'd be nice to evaluate it and @@ -265,6 +272,7 @@ export default function (vm) { // The target should exist, but there are ways for it not to (e.g. #4203). if (target) { spriteVariableOptions = target.getAllVariableNamesInScopeByType('', true); + sort(spriteVariableOptions); } const spriteVariableMenuItems = spriteVariableOptions.map(variable => [variable, variable]); return spriteOptions.concat(spriteVariableMenuItems); diff --git a/src/lib/default-project/index.js b/src/lib/default-project/index.js index df1154b6e8a7b34534c5370790164edcb375a408..92f01c908c1b3dfd05a71636fc71cd0eddfb3181 100644 --- a/src/lib/default-project/index.js +++ b/src/lib/default-project/index.js @@ -1,4 +1,3 @@ -import {TextEncoder} from 'text-encoding'; import projectData from './project-data'; /* eslint-disable import/no-unresolved */ @@ -9,8 +8,16 @@ import costume1 from '!raw-loader!./09dc888b0b7df19f70d81588ae73420e.svg'; import costume2 from '!raw-loader!./3696356a03a8d938318876a593572843.svg'; /* eslint-enable import/no-unresolved */ -const encoder = new TextEncoder(); const defaultProject = translator => { + let _TextEncoder; + if (typeof TextEncoder === 'undefined') { + _TextEncoder = require('text-encoding').TextEncoder; + } else { + /* global TextEncoder */ + _TextEncoder = TextEncoder; + } + const encoder = new _TextEncoder(); + const projectJson = projectData(translator); return [{ id: 0, diff --git a/src/lib/download-blob.js b/src/lib/download-blob.js new file mode 100644 index 0000000000000000000000000000000000000000..13d53538c60107d59ce6be9de143ad5d5b5120e7 --- /dev/null +++ b/src/lib/download-blob.js @@ -0,0 +1,17 @@ +export default (filename, blob) => { + const downloadLink = document.createElement('a'); + document.body.appendChild(downloadLink); + + // Use special ms version if available to get it working on Edge. + if (navigator.msSaveOrOpenBlob) { + navigator.msSaveOrOpenBlob(blob, filename); + return; + } + + const url = window.URL.createObjectURL(blob); + downloadLink.href = url; + downloadLink.download = filename; + downloadLink.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(downloadLink); +}; diff --git a/src/lib/download-url.js b/src/lib/download-url.js deleted file mode 100644 index e01cb1f0890bf103d704c661b0795bce721418bd..0000000000000000000000000000000000000000 --- a/src/lib/download-url.js +++ /dev/null @@ -1,13 +0,0 @@ -export default (filename, url) => { - const pom = document.createElement('a'); - pom.setAttribute('href', url); - pom.setAttribute('download', filename); - - if (document.createEvent) { - const event = document.createEvent('MouseEvents'); - event.initEvent('click', true, true); - pom.dispatchEvent(event); - } else { - pom.click(); - } -}; diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js index 74f3c64a8625c05610c4e0d505484e56fc4a8600..483f31edf61c95a08d6aa38522df9eda52d0aac7 100644 --- a/src/lib/file-uploader.js +++ b/src/lib/file-uploader.js @@ -21,22 +21,25 @@ const extractFileName = function (nameExt) { * @param {Function} onload The function that handles loading the file */ const handleFileUpload = function (fileInput, onload) { - let thisFile = null; - const reader = new FileReader(); - reader.onload = () => { - // Reset the file input value now that we have everything we need - // so that the user can upload the same sound multiple times if - // they choose - fileInput.value = null; - const fileType = thisFile.type; - const fileName = extractFileName(thisFile.name); - - onload(reader.result, fileType, fileName); + const readFile = (i, files) => { + if (i === files.length) { + // Reset the file input value now that we have everything we need + // so that the user can upload the same sound multiple times if + // they choose + fileInput.value = null; + return; + } + const file = files[i]; + const reader = new FileReader(); + reader.onload = () => { + const fileType = file.type; + const fileName = extractFileName(file.name); + onload(reader.result, fileType, fileName); + readFile(i + 1, files); + }; + reader.readAsArrayBuffer(file); }; - if (fileInput.files) { - thisFile = fileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + readFile(0, fileInput.files); }; /** diff --git a/test/fixtures/movie.wav b/test/fixtures/movie.wav new file mode 100644 index 0000000000000000000000000000000000000000..79c10d2146204fa108038bc4a67c853ec81a4707 Binary files /dev/null and b/test/fixtures/movie.wav differ diff --git a/test/fixtures/sneaker.wav b/test/fixtures/sneaker.wav new file mode 100644 index 0000000000000000000000000000000000000000..01c4a17597b5e747368094e61c2e8d0d3414cc2b Binary files /dev/null and b/test/fixtures/sneaker.wav differ diff --git a/test/integration/backdrops.test.js b/test/integration/backdrops.test.js index 09d398bd14a894322306151a4e56baecab1154a5..80a4a5650352a0710c2590cb0e31b4c5ae262e72 100644 --- a/test/integration/backdrops.test.js +++ b/test/integration/backdrops.test.js @@ -4,6 +4,7 @@ import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, + findByText, findByXpath, getDriver, getLogs, @@ -48,4 +49,30 @@ describe('Working with backdrops', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test.only('Adding multiple backdrops at the same time', async () => { + const files = [ + path.resolve(__dirname, '../fixtures/gh-3582-png.png'), + path.resolve(__dirname, '../fixtures/100-100.svg') + ]; + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + + const buttonXpath = '//button[@aria-label="Choose a Backdrop"]'; + const fileXpath = `${buttonXpath}/following-sibling::div//input[@type="file"]`; + + const el = await findByXpath(buttonXpath); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath(fileXpath); + await input.sendKeys(files.join('\n')); + + await clickXpath('//span[text()="Stage"]'); + await findByText('gh-3582-png', scope.costumesTab); + await findByText('100-100', scope.costumesTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js index 282ae3574bbc026d5b6e2b6f551b270c84a53a83..6095faf3b40ff7ee090e2baf67948bdffc7a1f9e 100644 --- a/test/integration/costumes.test.js +++ b/test/integration/costumes.test.js @@ -4,6 +4,7 @@ import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, + findByText, findByXpath, getDriver, getLogs, @@ -175,4 +176,25 @@ describe('Working with costumes', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test.only('Adding multiple costumes at the same time', async () => { + const files = [ + path.resolve(__dirname, '../fixtures/gh-3582-png.png'), + path.resolve(__dirname, '../fixtures/100-100.svg') + ]; + await loadUri(uri); + await clickText('Costumes'); + const el = await findByXpath('//button[@aria-label="Choose a Costume"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(files.join('\n')); + + await findByText('gh-3582-png', scope.costumesTab); + await findByText('100-100', scope.costumesTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/integration/sounds.test.js b/test/integration/sounds.test.js index 09bfdc5f1d8300a8be2554b69b8818a94f60fa5b..4dc9fc55cc075149e96292460d4d108eb1c50b24 100644 --- a/test/integration/sounds.test.js +++ b/test/integration/sounds.test.js @@ -4,6 +4,7 @@ import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, + findByText, findByXpath, getDriver, getLogs, @@ -110,4 +111,26 @@ describe('Working with sounds', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test.only('Adding multiple sounds at the same time', async () => { + const files = [ + path.resolve(__dirname, '../fixtures/movie.wav'), + path.resolve(__dirname, '../fixtures/sneaker.wav') + ]; + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + await clickText('Sounds'); + const el = await findByXpath('//button[@aria-label="Choose a Sound"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(files.join('\n')); + + await findByText('movie', scope.soundsTab); + await findByText('sneaker', scope.soundsTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/integration/sprites.test.js b/test/integration/sprites.test.js index 76cfae1213c3ce62e27891ff73847166a1ea7709..d70e75d2ccd74a5d02b600b698fd6d35d262f792 100644 --- a/test/integration/sprites.test.js +++ b/test/integration/sprites.test.js @@ -150,4 +150,25 @@ describe('Working with sprites', () => { await expect(logs).toEqual([]); }); + test.only('Adding multiple sprites at the same time', async () => { + const files = [ + path.resolve(__dirname, '../fixtures/gh-3582-png.png'), + path.resolve(__dirname, '../fixtures/100-100.svg') + ]; + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + const el = await findByXpath('//button[@aria-label="Choose a Sprite"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(files.join('\n')); + + await findByText('gh-3582-png', scope.spriteTile); + await findByText('100-100', scope.spriteTile); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + });