diff --git a/.travis.yml b/.travis.yml index 9290080d2f3f391b0cc52bb7db6b0fc1783f4e54..36e41544b0a54af7ff0ac476ca6426ee3057cedc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ before_deploy: - > if [ -z "$BEFORE_DEPLOY_RAN" ]; then npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) - if [ "$TRAVIS_BRANCH" == "develop" ]; then export NPM_TAG=develop; fi + if [ "$TRAVIS_BRANCH" == "master" ]; then export NPM_TAG=stable; fi git config --global user.email $(git log --pretty=format:"%ae" -n1) git config --global user.name $(git log --pretty=format:"%an" -n1) export BEFORE_DEPLOY_RAN=true diff --git a/package.json b/package.json index c27a39fa2e8e80c8def967f88779f9b9225e7331..b6fafe6186b3be993f34224e2c762e97f9d41be2 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.1548885087", - "scratch-l10n": "3.1.20190130142816", + "scratch-blocks": "0.1.0-prerelease.1549376808", + "scratch-l10n": "3.1.20190206143031", "scratch-paint": "0.2.0-prerelease.20190114205252", "scratch-render": "0.1.0-prerelease.20190128154859", "scratch-storage": "1.2.2", "scratch-svg-renderer": "0.2.0-prerelease.20190125192231", - "scratch-vm": "0.2.0-prerelease.20190130220715", + "scratch-vm": "0.2.0-prerelease.20190205221329", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/blocks/blocks.css b/src/components/blocks/blocks.css index fd66c68bd76335edbc5cfe64a2b349a7f51d778a..583f587f79764fe7f5af191757c4f874e5832c40 100644 --- a/src/components/blocks/blocks.css +++ b/src/components/blocks/blocks.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .blocks { height: 100%; @@ -80,6 +81,7 @@ This does not prevent user interaction on the blocks themselves. */ pointer-events: none; + z-index: $z-index-drag-layer; /* make blocks match gui drag layer */ } /* diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 825f6096f9f16c357076201fde8c9b537ecb61f8..647a90fd05774457c19095713c72bbf240ee84a3 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; +import bowser from 'bowser'; import React from 'react'; import Box from '../box/box.jsx'; @@ -151,11 +152,18 @@ class MenuBar extends React.Component { 'handleClickSeeCommunity', 'handleClickShare', 'handleCloseFileMenuAndThen', + 'handleKeyPress', 'handleLanguageMouseUp', 'handleRestoreOption', 'restoreOptionMessage' ]); } + componentDidMount () { + document.addEventListener('keydown', this.handleKeyPress); + } + componentWillUnmount () { + document.removeEventListener('keydown', this.handleKeyPress); + } handleClickNew () { let readyToReplaceProject = true; // if the project is dirty, and user owns the project, we will autosave. @@ -219,6 +227,13 @@ class MenuBar extends React.Component { fn(); }; } + handleKeyPress (event) { + const modifier = bowser.mac ? event.metaKey : event.ctrlKey; + if (modifier && event.key === 's') { + this.props.onClickSave(); + event.preventDefault(); + } + } handleLanguageMouseUp (e) { if (!this.props.languageMenuOpen) { this.props.onClickLanguage(e); diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 98a01db1bf1cf1819da808b4d3c2e9b08129bdef..d548ccff8db04a5819e5367faee5b04cdde6f7bb 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -87,6 +87,7 @@ class Blocks extends React.Component { componentDidMount () { this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures; + this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, @@ -141,11 +142,7 @@ class Blocks extends React.Component { // different from the previously rendered toolbox xml. // Do not check against prevProps.toolboxXML because that may not have been rendered. if (this.props.isVisible && this.props.toolboxXML !== this._renderedToolboxXML) { - // rather than update the toolbox "sync" -- update it in the next frame - clearTimeout(this.toolboxUpdateTimeout); - this.toolboxUpdateTimeout = setTimeout(() => { - this.updateToolbox(); - }, 0); + this.requestToolboxUpdate(); } if (this.props.isVisible === prevProps.isVisible) { @@ -177,15 +174,19 @@ class Blocks extends React.Component { this.workspace.dispose(); clearTimeout(this.toolboxUpdateTimeout); } - + requestToolboxUpdate () { + clearTimeout(this.toolboxUpdateTimeout); + this.toolboxUpdateTimeout = setTimeout(() => { + this.updateToolbox(); + }, 0); + } setLocale () { this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); this.props.vm.setLocale(this.props.locale, this.props.messages) .then(() => { this.workspace.getFlyout().setRecyclingEnabled(false); this.props.vm.refreshWorkspace(); - // refreshWorkspace will cause a toolbox update - // wait for update to go through before reenabling recycling + this.requestToolboxUpdate(); this.withToolboxUpdates(() => { this.workspace.getFlyout().setRecyclingEnabled(true); }); @@ -454,6 +455,7 @@ class Blocks extends React.Component { .then(blocks => this.props.vm.shareBlocksToTarget(blocks, this.props.vm.editingTarget.id)) .then(() => { this.props.vm.refreshWorkspace(); + this.updateToolbox(); // To show new variables/custom blocks }); } render () { @@ -526,7 +528,7 @@ Blocks.propTypes = { extensionLibraryVisible: PropTypes.bool, isRtl: PropTypes.bool, isVisible: PropTypes.bool, - locale: PropTypes.string, + locale: PropTypes.string.isRequired, messages: PropTypes.objectOf(PropTypes.string), onActivateColorPicker: PropTypes.func, onActivateCustomProcedures: PropTypes.func, diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 2b71975c4e366a58ae876d67fe0cce479c40475b..83b27d4c3252cccd83c1f1567d73d486c4790b5d 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -14,7 +14,6 @@ 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 getCostumeUrl from '../lib/get-costume-url'; import { closeCameraCapture, @@ -139,12 +138,6 @@ class CostumeTab extends React.Component { this.setState({selectedCostumeIndex: target.currentCostume}); } } - getCostumeData (costumeItem) { - if (costumeItem.url) return costumeItem.url; - if (!costumeItem.asset) return null; - - return getCostumeUrl(costumeItem.asset); - } handleSelectCostume (costumeIndex) { this.props.vm.editingTarget.setCostume(costumeIndex); this.setState({selectedCostumeIndex: costumeIndex}); @@ -161,7 +154,7 @@ class CostumeTab extends React.Component { } handleExportCostume (costumeIndex) { const item = this.props.vm.editingTarget.sprite.costumes[costumeIndex]; - download(`${item.name}.${item.asset.dataFormat}`, this.getCostumeData(item)); + download(`${item.name}.${item.asset.dataFormat}`, item.asset.encodeDataURI()); } handleNewCostume (costume, fromCostumeLibrary) { if (fromCostumeLibrary) { diff --git a/src/css/z-index.css b/src/css/z-index.css index 9f48b52aa53f7b10c497d0eb10db784bf74dd2a5..51246965801be18b4b1a72bb88a3c5a21c8083a1 100644 --- a/src/css/z-index.css +++ b/src/css/z-index.css @@ -10,7 +10,6 @@ $z-index-stage-indicator: 45; $z-index-add-button: 46; $z-index-tooltip: 47; /* tooltips should go over add buttons if they overlap */ $z-index-monitor: 48; /* monitors go over add buttons */ -/* Block drag z-index: 50; set in scratch-blocks */ $z-index-card: 480; $z-index-alerts: 490; @@ -19,6 +18,7 @@ $z-index-loader: 500; $z-index-modal: 510; $z-index-drag-layer: 1000; +/* Block drag z-index: 1000; default 50 is overriden in blocks.css */ $z-index-monitor-dragging: 1010; $z-index-dragging-sprite: 1020; /* so it is draggable into other panes */ diff --git a/src/lib/cloud-manager-hoc.jsx b/src/lib/cloud-manager-hoc.jsx index 05047b435a606dda62614290aac17119e2e05f77..183ef24a495dc9fff9e6748a9134e6577337019f 100644 --- a/src/lib/cloud-manager-hoc.jsx +++ b/src/lib/cloud-manager-hoc.jsx @@ -15,7 +15,7 @@ import { } from '../reducers/alerts'; /* - * Higher Order Component to manage the connection to the cloud dserver. + * Higher Order Component to manage the connection to the cloud server. * @param {React.Component} WrappedComponent component to manage VM events for * @returns {React.Component} connected component with vm events bound to redux */ diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js index 12b503a20766666e7be909449c97965ea3bde8ca..74f3c64a8625c05610c4e0d505484e56fc4a8600 100644 --- a/src/lib/file-uploader.js +++ b/src/lib/file-uploader.js @@ -206,7 +206,7 @@ const spriteUpload = function (fileData, fileType, spriteName, storage, handleSp size: 100, rotationStyle: 'all around', direction: 90, - draggable: true, + draggable: false, currentCostume: 0, blocks: {}, variables: {}, diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js index 4217b6e6bca71b76bcf156c75d014cef051e3d4d..3443e05d2c0b5f5b763f28a5a146c10940251c5b 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -138,6 +138,18 @@ const motion = function (isStage, targetId) { `; }; +const xmlEscape = function (unsafe) { + return unsafe.replace(/[<>&'"]/g, c => { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '\'': return '''; + case '"': return '"'; + } + }); +}; + const looks = function (isStage, targetId, costumeName, backdropName) { const hello = ScratchBlocks.ScratchMsgs.translate('LOOKS_HELLO', 'Hello!'); const hmm = ScratchBlocks.ScratchMsgs.translate('LOOKS_HMM', 'Hmm...'); @@ -714,6 +726,10 @@ const makeToolboxXML = function (isStage, targetId, categoriesXML, costumeName = '', backdropName = '', soundName = '') { const gap = [categorySeparator]; + costumeName = xmlEscape(costumeName); + backdropName = xmlEscape(backdropName); + soundName = xmlEscape(soundName); + const everything = [ xmlOpen, motion(isStage, targetId), gap, diff --git a/src/playground/player.css b/src/playground/player.css index 355eeeecd49ccbaf5f29d8d06eebd676b31c2c57..a0a00f9aa288fdd4d88de5cb991af0d0183f4218 100644 --- a/src/playground/player.css +++ b/src/playground/player.css @@ -2,6 +2,14 @@ width: calc(480px + 1rem); } +.editor { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; +} + .stage-only * { box-sizing: border-box; } diff --git a/src/playground/player.jsx b/src/playground/player.jsx index 2235bbac703d01ebfe4003976a3ce65919b3eb95..8224ad6489c57ce5b071704de1adcdb776345b95 100644 --- a/src/playground/player.jsx +++ b/src/playground/player.jsx @@ -21,11 +21,7 @@ if (process.env.NODE_ENV === 'production' && typeof window === 'object') { import styles from './player.css'; const Player = ({isPlayerOnly, onSeeInside, projectId}) => ( - <Box - className={classNames({ - [styles.stageOnly]: isPlayerOnly - })} - > + <Box className={classNames(isPlayerOnly ? styles.stageOnly : styles.editor)}> {isPlayerOnly && <button onClick={onSeeInside}>{'See inside'}</button>} <GUI enableCommunity diff --git a/src/reducers/mode.js b/src/reducers/mode.js index e07b0cda008ccfe70658157e18b6f453df28020b..5fca13b5c56a70a2d75cd3c4e19eb73808229c0b 100644 --- a/src/reducers/mode.js +++ b/src/reducers/mode.js @@ -12,16 +12,14 @@ const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { case SET_FULL_SCREEN: - return { - isFullScreen: action.isFullScreen, - isPlayerOnly: state.isPlayerOnly - }; + return Object.assign({}, state, { + isFullScreen: action.isFullScreen + }); case SET_PLAYER: - return { - isFullScreen: state.isFullScreen, + return Object.assign({}, state, { isPlayerOnly: action.isPlayerOnly, hasEverEnteredEditor: state.hasEverEnteredEditor || !action.isPlayerOnly - }; + }); default: return state; } diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index 12549e93301389f4efedf47ff01791996868a69c..99320f577083bfaaaca08c425c0dda9ab921cd43 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -21,7 +21,8 @@ class SeleniumHelper { 'getSauceDriver', 'getLogs', 'loadUri', - 'rightClickText' + 'rightClickText', + 'waitUntilGone' ]); } @@ -119,6 +120,10 @@ class SeleniumHelper { return this.clickXpath(`//button//*[contains(text(), '${text}')]`); } + waitUntilGone (element) { + return this.driver.wait(until.stalenessOf(element)); + } + getLogs (whitelist) { if (!whitelist) { // Default whitelist diff --git a/test/integration/blocks.test.js b/test/integration/blocks.test.js index ec57f9c95917fc347fceb18408e6d137aab049b9..fe8dfe402e9d12f4e004c1ce990f885b0f18589a 100644 --- a/test/integration/blocks.test.js +++ b/test/integration/blocks.test.js @@ -183,6 +183,24 @@ describe('Working with the blocks', () => { await clickText('newname', scope.blocksTab); }); + test('Renaming costume with a special character should not break toolbox', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + + // Rename the costume + await clickText('Costumes'); + const el = await findByXpath("//input[@value='costume1']"); + await el.sendKeys('<NewCostume>'); + + // Make sure it is updated in the block menu + await clickText('Code'); + await clickText('Looks', scope.blocksTab); + await driver.sleep(500); // Wait for scroll to finish + await clickText('<NewCostume>', scope.blocksTab); + + await clickText('Sound', scope.blocksTab); + }); + // NOTE: This test describes the current behavior so that changes are not // introduced inadvertly, but I know this is not the desired behavior test('Adding costumes DOES NOT update the default costume name in the toolbox', async () => { @@ -218,4 +236,21 @@ describe('Working with the blocks', () => { await driver.sleep(500); // Wait for scroll to finish await clickText('Meow', scope.blocksTab); // Meow, not A Bass }); + + // Regression test for switching between editor/player causing toolbox to stop updating + test('"See inside" after being on project page re-initializing variables', async () => { + const playerUri = path.resolve(__dirname, '../../build/player.html'); + await loadUri(playerUri); + await clickText('See inside'); + await clickText('Variables'); + await driver.sleep(500); // Wait for scroll to finish + await clickText('my\u00A0variable'); + + await clickText('See Project Page'); + await clickText('See inside'); + + await clickText('Variables'); + await driver.sleep(500); // Wait for scroll to finish + await clickText('my\u00A0variable'); + }); }); diff --git a/test/integration/examples.test.js b/test/integration/examples.test.js index 7e8ff7a10c1736242f58b2c853878d04c720de24..aa867b0414503bbb5ab1460c24cfb6c8633aea75 100644 --- a/test/integration/examples.test.js +++ b/test/integration/examples.test.js @@ -4,13 +4,15 @@ import path from 'path'; import SeleniumHelper from '../helpers/selenium-helper'; const { + findByText, clickButton, clickText, clickXpath, findByXpath, getDriver, getLogs, - loadUri + loadUri, + waitUntilGone } = new SeleniumHelper(); let driver; @@ -29,7 +31,7 @@ describe('player example', () => { test('Load a project by ID', async () => { const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await new Promise(resolve => setTimeout(resolve, 2000)); + await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Go"]'); await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath('//img[@title="Stop"]'); diff --git a/test/integration/localization.test.js b/test/integration/localization.test.js index fc04c28b1b3b1432657906550d612a98ffb5c372..d2baa36c0406e50c24729b38e354905554c07a95 100644 --- a/test/integration/localization.test.js +++ b/test/integration/localization.test.js @@ -24,10 +24,9 @@ describe('Localization', () => { await driver.quit(); }); - test('Localization', async () => { + test('Switching languages', async () => { await driver.quit(); driver = getDriver(); - await loadUri(uri); // Add a sprite to make sure it stays when switching languages @@ -51,6 +50,20 @@ describe('Localization', () => { // After switching languages, make sure Apple sprite still exists await rightClickText('Apple', scope.spriteTile); // Make sure it is there + // Remounting re-attaches the beforeunload callback. Make sure to remove it + driver.executeScript('window.onbeforeunload = undefined;'); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + // Regression test for #4476, blocks in wrong language when loaded with locale + test('Loading with locale shows correct blocks', async () => { + await loadUri(`${uri}?locale=de`); + await clickXpath('//button[@title="Ausprobieren!"]'); // "Try It" + await clickText('Fühlen'); // Sensing category in German + await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks to scroll + await clickText('Antwort'); // Find the "answer" block in German const logs = await getLogs(); await expect(logs).toEqual([]); }); diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js index b5fa04b8deb8a35cf8abe3657c51b9746d292474..e0c5e5d1f9dc27392780ae5a345fd6b157a9fb14 100644 --- a/test/integration/project-loading.test.js +++ b/test/integration/project-loading.test.js @@ -4,11 +4,13 @@ import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, + findByText, findByXpath, getDriver, getLogs, loadUri, - scope + scope, + waitUntilGone } = new SeleniumHelper(); const uri = path.resolve(__dirname, '../../build/index.html'); @@ -37,7 +39,7 @@ describe('Loading scratch gui', () => { const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await new Promise(resolve => setTimeout(resolve, 3000)); + await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Go"]'); await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath('//img[@title="Stop"]'); @@ -58,7 +60,7 @@ describe('Loading scratch gui', () => { .setSize(1920, 1080); const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); - await new Promise(resolve => setTimeout(resolve, 2000)); + await waitUntilGone(findByText('Loading')); await clickXpath('//img[@title="Full Screen Control"]'); await new Promise(resolve => setTimeout(resolve, 500)); await clickXpath('//img[@title="Go"]'); diff --git a/test/unit/reducers/mode-reducer.test.js b/test/unit/reducers/mode-reducer.test.js new file mode 100644 index 0000000000000000000000000000000000000000..641398f2d5c618b617df44efd46f3f2f87e1fe47 --- /dev/null +++ b/test/unit/reducers/mode-reducer.test.js @@ -0,0 +1,53 @@ +/* eslint-env jest */ +import modeReducer from '../../../src/reducers/mode'; + +const SET_FULL_SCREEN = 'scratch-gui/mode/SET_FULL_SCREEN'; +const SET_PLAYER = 'scratch-gui/mode/SET_PLAYER'; + +test('initialState', () => { + let defaultState; + /* modeReducer(state, action) */ + expect(modeReducer(defaultState, {type: 'anything'})).toBeDefined(); +}); + +test('set full screen mode', () => { + const previousState = { + showBranding: false, + isFullScreen: false, + isPlayerOnly: false, + hasEverEnteredEditor: true + }; + const action = { + type: SET_FULL_SCREEN, + isFullScreen: true + }; + const newState = { + showBranding: false, + isFullScreen: true, + isPlayerOnly: false, + hasEverEnteredEditor: true + }; + /* modeReducer(state, action) */ + expect(modeReducer(previousState, action)).toEqual(newState); +}); + +test('set player mode', () => { + const previousState = { + showBranding: false, + isFullScreen: false, + isPlayerOnly: false, + hasEverEnteredEditor: true + }; + const action = { + type: SET_PLAYER, + isPlayerOnly: true + }; + const newState = { + showBranding: false, + isFullScreen: false, + isPlayerOnly: true, + hasEverEnteredEditor: true + }; + /* modeReducer(state, action) */ + expect(modeReducer(previousState, action)).toEqual(newState); +});