Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34d5ed67e | ||
|
|
af4ad81811 | ||
|
|
c368de81fa | ||
|
|
0104f5bec8 | ||
|
|
ae8624e7e7 | ||
|
|
7f8b84829e | ||
|
|
d4078a795f | ||
|
|
f381a6a0d8 | ||
|
|
962ce680c9 | ||
|
|
630ba84350 | ||
|
|
51843d6bef | ||
|
|
02be2155d9 | ||
|
|
cca695b54e | ||
|
|
4e633cb596 | ||
|
|
8dc343c90d | ||
|
|
369eb8c3cf | ||
|
|
de9dbe08f4 | ||
|
|
c050829a75 | ||
|
|
6e57ea1c91 | ||
|
|
53d09e2162 | ||
|
|
5d9afb008a | ||
|
|
d19cf91205 | ||
|
|
4f70b0f924 | ||
|
|
f8a0f46959 | ||
|
|
faf0dd2f13 | ||
|
|
cbb908831d | ||
|
|
b44c0c355a | ||
|
|
ffa364ed64 | ||
|
|
59f428900c | ||
|
|
48245be395 | ||
|
|
efed4d3721 | ||
|
|
95532e147d | ||
|
|
3a56e609a5 | ||
|
|
e63842a336 | ||
|
|
c9173427b9 | ||
|
|
3e5b65072c | ||
|
|
74828b3b09 | ||
|
|
e77162791f | ||
|
|
0c17cd956d | ||
|
|
287b29019a | ||
|
|
ed3b58bfb5 | ||
|
|
c2cd6f21da | ||
|
|
066e9dd6bd | ||
|
|
5a0860228e | ||
|
|
f2291d963c | ||
|
|
97885c7518 | ||
|
|
a23dd3e349 | ||
|
|
79f3f6f3f8 | ||
|
|
175395cce3 | ||
|
|
ee67ddf56f | ||
|
|
4b75bc8f9d | ||
|
|
8a4adccae5 | ||
|
|
9908d44d81 | ||
|
|
d2c74c5198 | ||
|
|
80e91a3c5e | ||
|
|
797ce1894b | ||
|
|
f41e1c829a | ||
|
|
fa5994abbd | ||
|
|
76f5cfe1cf | ||
|
|
fcbf1b051b | ||
|
|
1dcba6ecd5 | ||
|
|
79277a1227 | ||
|
|
e476548c64 | ||
|
|
8adb097cd1 | ||
|
|
403a7a5883 | ||
|
|
dd5562323e | ||
|
|
f261f4614a | ||
|
|
a93248df31 | ||
|
|
1fa8742dec | ||
|
|
8b0f2c16bc | ||
|
|
57988d63c2 | ||
|
|
c3d3a7a464 | ||
|
|
e1102f91ef | ||
|
|
e10a3f282f | ||
|
|
0ef59001f1 | ||
|
|
ad96f2eb30 | ||
|
|
5fac48acc9 | ||
|
|
b19139696b | ||
|
|
a5ccbfb8b9 | ||
|
|
7960eed191 | ||
|
|
cb0e24b5be | ||
|
|
ccc12a02c3 | ||
|
|
d505af579a | ||
|
|
504097b495 | ||
|
|
484eb6bc9f | ||
|
|
96392659fd | ||
|
|
2c16a16da5 | ||
|
|
ed0db53f36 | ||
|
|
f244da7705 | ||
|
|
3d1205e453 | ||
|
|
3b1889b941 | ||
|
|
a908345491 | ||
|
|
50de01086a | ||
|
|
57863d99a1 | ||
|
|
3e93db7c0b | ||
|
|
28f4cc737d | ||
|
|
d1d98337c8 | ||
|
|
bb80fa49ac | ||
|
|
38e4c5bbfa | ||
|
|
b3e5dd88fb | ||
|
|
6027bb9011 | ||
|
|
e799708707 | ||
|
|
b699e76972 | ||
|
|
914ba4910b | ||
|
|
748f8559e4 | ||
|
|
8fae109811 | ||
|
|
7980c23ecc | ||
|
|
9638444a49 | ||
|
|
21d6ce111e | ||
|
|
fcb6de39f3 | ||
|
|
3c6e9723e3 |
No files matched your search
+1
-1
@@ -14,6 +14,6 @@ npm-*
|
||||
/dist
|
||||
/.opt-in
|
||||
|
||||
# generated translation files
|
||||
# Generated translation files
|
||||
/translations
|
||||
/locale
|
||||
@@ -74,7 +74,7 @@ See [jest cli docs](https://facebook.github.io/jest/docs/en/cli.html#content) fo
|
||||
|
||||
*NOTE: If you're a windows user, please run these scripts in Windows `cmd.exe` instead of Git Bash/MINGW64.*
|
||||
|
||||
Before running any test, make sure you have run `npm install` from this (scratch-gui) repository's top level.
|
||||
Before running any tests, make sure you have run `npm install` from this (scratch-gui) repository's top level.
|
||||
|
||||
#### Main testing command
|
||||
|
||||
@@ -194,5 +194,60 @@ Further reading: [Stack Overflow](https://stackoverflow.com/questions/46602286/n
|
||||
You can publish the GUI to github.io so that others on the Internet can view it.
|
||||
[Read the wiki for a step-by-step guide.](https://github.com/LLK/scratch-gui/wiki/Publishing-to-GitHub-Pages)
|
||||
|
||||
## Understanding the project state machine
|
||||
|
||||
Since so much code throughout scratch-gui depends on the state of the project, which goes through many different phases of loading, displaying and saving, we created a "finite state machine" to make it clear which state it is in at any moment. This is contained in the file src/reducers/project-state.js .
|
||||
|
||||
It can be hard to understand the code in src/reducers/project-state.js . There are several types of data and functions used, which relate to each other:
|
||||
|
||||
### Loading states
|
||||
|
||||
These include state constant strings like:
|
||||
|
||||
* `NOT_LOADED` (the default state),
|
||||
* `ERROR`,
|
||||
* `FETCHING_WITH_ID`,
|
||||
* `LOADING_VM_WITH_ID`,
|
||||
* `REMIXING`,
|
||||
* `SHOWING_WITH_ID`,
|
||||
* `SHOWING_WITHOUT_ID`,
|
||||
* etc.
|
||||
|
||||
### Transitions
|
||||
|
||||
These are names for the action which causes a state change. Some examples are:
|
||||
|
||||
* `START_FETCHING_NEW`,
|
||||
* `DONE_FETCHING_WITH_ID`,
|
||||
* `DONE_LOADING_VM_WITH_ID`,
|
||||
* `SET_PROJECT_ID`,
|
||||
* `START_AUTO_UPDATING`,
|
||||
|
||||
### How transitions relate to loading states
|
||||
|
||||
As this diagram of the project state machine shows, various transition actions can move us from one loading state to another:
|
||||
|
||||

|
||||
|
||||
_Note: for clarity, the diagram above excludes states and transitions relating to error handling._
|
||||
|
||||
#### Example
|
||||
|
||||
Here's an example of how states transition.
|
||||
|
||||
Suppose a user clicks on a project, and the page starts to load with url https://scratch.mit.edu/projects/123456 .
|
||||
|
||||
Here's what will happen in the project state machine:
|
||||
|
||||

|
||||
|
||||
1. When the app first mounts, the project state is `NOT_LOADED`.
|
||||
2. The `SET_PROJECT_ID` redux action is dispatched (from src/lib/project-fetcher-hoc.jsx), with `projectId` set to `123456`. This transitions the state from `NOT_LOADED` to `FETCHING_WITH_ID`.
|
||||
3. The `FETCHING_WITH_ID` state. In src/lib/project-fetcher-hoc.jsx, the `projectId` value `123456` is used to request the data for that project from the server.
|
||||
4. When the server responds with the data, src/lib/project-fetcher-hoc.jsx dispatches the `DONE_FETCHING_WITH_ID` action, with `projectData` set. This transitions the state from `FETCHING_WITH_ID` to `LOADING_VM_WITH_ID`.
|
||||
5. The `LOADING_VM_WITH_ID` state. In src/lib/vm-manager-hoc.jsx, we load the `projectData` into Scratch's virtual machine ("the vm").
|
||||
6. When loading is done, src/lib/vm-manager-hoc.jsx dispatches the `DONE_LOADING_VM_WITH_ID` action. This transitions the state from `LOADING_VM_WITH_ID` to `SHOWING_WITH_ID`
|
||||
7. The `SHOWING_WITH_ID` state. Now the project appears normally and is playable and editable.
|
||||
|
||||
## Donate
|
||||
We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you!
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1381px" height="1012px" viewBox="-0.5 -0.5 1381 1012" content="<mxfile host="www.draw.io" modified="2020-02-04T16:31:44.889Z" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36" etag="8a2X5Jj65jOGG04xBBh1" version="12.6.4" type="device"><diagram name="Page-1" id="58cdce13-f638-feb5-8d6f-7d28b1aa9fa0">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 truncated
|
||||
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Generated
+265
-43
@@ -20,6 +20,17 @@
|
||||
"output-file-sync": "^2.0.0",
|
||||
"slash": "^2.0.0",
|
||||
"source-map": "^0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
@@ -937,6 +948,12 @@
|
||||
"integrity": "sha512-8DvLPFbipP7O8fii6xItaA041fdW4pdr63m34xlSs15sYP5fX9yiZiO3G44qk5eBPbFNQTG7uxMd5Jo+cVS5Ow==",
|
||||
"dev": true
|
||||
},
|
||||
"@testim/chrome-version": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.0.7.tgz",
|
||||
"integrity": "sha512-8UT/J+xqCYfn3fKtOznAibsHpiuDshCb0fwgWxRazTT19Igp9ovoXMPhXyLD6m3CKQGTMHgqoxaFfMWaL40Rnw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz",
|
||||
@@ -1727,7 +1744,7 @@
|
||||
},
|
||||
"chalk": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
@@ -1826,6 +1843,17 @@
|
||||
"loader-utils": "^1.0.2",
|
||||
"mkdirp": "^0.5.1",
|
||||
"pify": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"babel-messages": {
|
||||
@@ -1932,7 +1960,7 @@
|
||||
},
|
||||
"babel-plugin-syntax-object-rest-spread": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
|
||||
"integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=",
|
||||
"dev": true
|
||||
},
|
||||
@@ -1994,6 +2022,15 @@
|
||||
"integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"slash": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
|
||||
@@ -2532,6 +2569,17 @@
|
||||
"ssri": "^5.2.4",
|
||||
"unique-filename": "^1.1.0",
|
||||
"y18n": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cache-base": {
|
||||
@@ -2699,16 +2747,28 @@
|
||||
}
|
||||
},
|
||||
"chromedriver": {
|
||||
"version": "78.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-78.0.1.tgz",
|
||||
"integrity": "sha512-eOsyFk4xb9EECs1VMrDbxO713qN+Bu1XUE8K9AuePc3839TPdAegg72kpXSzkeNqRNZiHbnJUItIVCLFkDqceA==",
|
||||
"version": "80.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-80.0.0.tgz",
|
||||
"integrity": "sha512-W4tIbaOve7HeGFLnbbZMV4AUlnBaapL+H41fvDFKOXCmUvgPhxVN9y/c3EgmsOcokLQkqxpOC/txEujms1eT0w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@testim/chrome-version": "^1.0.7",
|
||||
"del": "^4.1.1",
|
||||
"extract-zip": "^1.6.7",
|
||||
"mkdirp": "^0.5.1",
|
||||
"request": "^2.88.0",
|
||||
"tcp-port-used": "^1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ci-info": {
|
||||
@@ -3035,6 +3095,17 @@
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "^2.5.4",
|
||||
"run-queue": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"copy-descriptor": {
|
||||
@@ -4209,6 +4280,15 @@
|
||||
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -4866,6 +4946,17 @@
|
||||
"debug": "2.6.9",
|
||||
"mkdirp": "0.5.1",
|
||||
"yauzl": "2.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"extsprintf": {
|
||||
@@ -7405,6 +7496,15 @@
|
||||
"requires": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7456,6 +7556,15 @@
|
||||
"integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
|
||||
@@ -7489,6 +7598,15 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -7932,6 +8050,15 @@
|
||||
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
|
||||
@@ -8325,6 +8452,17 @@
|
||||
"mkdirp": "^0.5.1",
|
||||
"natural-compare": "^1.4.0",
|
||||
"pretty-format": "^21.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest-util": {
|
||||
@@ -8347,6 +8485,15 @@
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
|
||||
"integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9132,13 +9279,10 @@
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.3.tgz",
|
||||
"integrity": "sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==",
|
||||
"dev": true
|
||||
},
|
||||
"mkpath": {
|
||||
"version": "1.0.0",
|
||||
@@ -9211,6 +9355,15 @@
|
||||
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz",
|
||||
@@ -9240,6 +9393,17 @@
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "^2.5.4",
|
||||
"run-queue": "^1.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
@@ -9804,6 +9968,17 @@
|
||||
"graceful-fs": "^4.1.11",
|
||||
"is-plain-obj": "^1.1.0",
|
||||
"mkdirp": "^0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"p-cancelable": {
|
||||
@@ -9876,9 +10051,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"papaparse": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-4.6.2.tgz",
|
||||
"integrity": "sha512-P/4p6S6wZyXAjcFPnJAjFn7lxMIkd+23prFELhcbfWswQfg7dG1XJfV8/lKvk5lu5BGSSJZFHSDMejjFcB1feg==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.1.1.tgz",
|
||||
"integrity": "sha512-KPkW4GNQxunmYTeJIjHFrvilcNuHBWrfgbyvmagEmfGOA4hnP1WIkPbv4yABhj1Nam3as4w+7MBiI27BntwqVg==",
|
||||
"dev": true
|
||||
},
|
||||
"parallel-transform": {
|
||||
@@ -10170,6 +10345,15 @@
|
||||
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -11051,7 +11235,7 @@
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
|
||||
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
@@ -11524,9 +11708,9 @@
|
||||
}
|
||||
},
|
||||
"scratch-blocks": {
|
||||
"version": "0.1.0-prerelease.1576850350",
|
||||
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1576850350.tgz",
|
||||
"integrity": "sha512-P17eUhKZ6RJqr/3BJwx5tSrYPDZlM9azt3mD5kInMfEknkcmYuBeyEffj1/Gl6T/KoWPDNdR+CHuy6FVQtMzmA==",
|
||||
"version": "0.1.0-prerelease.1580911107",
|
||||
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1580911107.tgz",
|
||||
"integrity": "sha512-2+bpWEL/2N569nGVPT4nZ5l9lwfezhsImJmw7XQAsJxzvRt9Ou9ZnkFAJN1trTZq/8krYoqjkXOoSe6gmbXW+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"exports-loader": "0.6.3",
|
||||
@@ -11534,9 +11718,9 @@
|
||||
}
|
||||
},
|
||||
"scratch-l10n": {
|
||||
"version": "3.7.20191219145348",
|
||||
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.7.20191219145348.tgz",
|
||||
"integrity": "sha512-qfduPyCT0u6i7dURBLVGZw+jVQsz7tevRqovY85DsBe3A2IJZR3HlWkncDlLTVUGYQBY65EyO3CHUIS8Nex3MA==",
|
||||
"version": "3.7.20200205213636",
|
||||
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.7.20200205213636.tgz",
|
||||
"integrity": "sha512-e06mKQ1b2rVEjQPX84e872+4/1p8lLD9ofXZXI/Tti7KPeGilIiJmrWCfjbVtXPk7dOTYaOb55Ke0c0rdR8K+w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/cli": "^7.1.2",
|
||||
@@ -11546,9 +11730,9 @@
|
||||
}
|
||||
},
|
||||
"scratch-paint": {
|
||||
"version": "0.2.0-prerelease.20191217213717",
|
||||
"resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-0.2.0-prerelease.20191217213717.tgz",
|
||||
"integrity": "sha512-TPEYgt7nDEbVnmA/DiiasuYgmGUyrGizhMLzgwx2W8NB8RlEV/PVHbQAsZKoRu+dj8F8bPpz/PYpQOXBUJjNuA==",
|
||||
"version": "0.2.0-prerelease.20200204235639",
|
||||
"resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-0.2.0-prerelease.20200204235639.tgz",
|
||||
"integrity": "sha512-RMBvNrdmo/xgDuP3speGx0WfsYEttmeKI4zu9HiAYHPwRDtKOtRghnNxxFCrcpzG/qOEIV9m2eshVyGj8W9d0g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@scratch/paper": "0.11.20190729152410",
|
||||
@@ -11559,7 +11743,7 @@
|
||||
"minilog": "3.1.0",
|
||||
"parse-color": "1.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20191217211338"
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20200109070519"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": {
|
||||
@@ -11661,9 +11845,9 @@
|
||||
}
|
||||
},
|
||||
"scratch-render": {
|
||||
"version": "0.1.0-prerelease.20191217212645",
|
||||
"resolved": "https://registry.npmjs.org/scratch-render/-/scratch-render-0.1.0-prerelease.20191217212645.tgz",
|
||||
"integrity": "sha512-j7NF/lgeWE09cnQzVW8uW28kUZ/QstWTM4slM3Rx8Uuip6tGb8N84Yj/8mMKU+DRXU1bT3yIh8JRSuguzd3Tvg==",
|
||||
"version": "0.1.0-prerelease.20200128205403",
|
||||
"resolved": "https://registry.npmjs.org/scratch-render/-/scratch-render-0.1.0-prerelease.20200128205403.tgz",
|
||||
"integrity": "sha512-Lj2oLSFLImc8v+w6crVRq2IzmpE0Ag5B+ED4vIqkILa8euiB56av3bZTOuft2wWYCq45CzxNvXjsxhkkknVeAw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"grapheme-breaker": "0.3.2",
|
||||
@@ -11673,7 +11857,7 @@
|
||||
"minilog": "3.1.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"scratch-storage": "^1.0.0",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20191217211338",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20200109070519",
|
||||
"twgl.js": "4.4.0"
|
||||
}
|
||||
},
|
||||
@@ -11721,16 +11905,16 @@
|
||||
}
|
||||
},
|
||||
"scratch-svg-renderer": {
|
||||
"version": "0.2.0-prerelease.20191217211338",
|
||||
"resolved": "https://registry.npmjs.org/scratch-svg-renderer/-/scratch-svg-renderer-0.2.0-prerelease.20191217211338.tgz",
|
||||
"integrity": "sha512-38L23abfi8QOMbPc0DOdLYRvEi19iSI7/5xf9geblONSNF6RYrENSTYvmVw9mOxHYj8FSODKwLhkp5OD6gpekg==",
|
||||
"version": "0.2.0-prerelease.20200109070519",
|
||||
"resolved": "https://registry.npmjs.org/scratch-svg-renderer/-/scratch-svg-renderer-0.2.0-prerelease.20200109070519.tgz",
|
||||
"integrity": "sha512-THdZW9gA8mFKvDImHDrWWhvyOymUW3JgMBjpPsaFk2URl1kfNIRQIndfwto2B8a8YbtuyKSv6CWW8Nu+VzBZVA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"base64-js": "1.2.1",
|
||||
"base64-loader": "1.0.0",
|
||||
"minilog": "3.1.0",
|
||||
"scratch-render-fonts": "1.0.0-prerelease.20180906193204",
|
||||
"transformation-matrix": "1.14.1"
|
||||
"transformation-matrix": "1.15.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"base64-js": {
|
||||
@@ -11748,9 +11932,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"scratch-vm": {
|
||||
"version": "0.2.0-prerelease.20191119203901",
|
||||
"resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20191119203901.tgz",
|
||||
"integrity": "sha512-og+u9XHgCN9HFK14RVcOWawFMexnfVh7fLVRq36DSidUQkUPhS4Ls4V9qGCqOAV3WIlI0EXoXy6KdLfieVX+SQ==",
|
||||
"version": "0.2.0-prerelease.20191227164934",
|
||||
"resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20191227164934.tgz",
|
||||
"integrity": "sha512-loaXhKW4pcY5f/+ARXFAsC0LYC22R/QsTTFxLdfhiWDnbO/ZRYQ4FmAlSzxCx0d2VPhI3uu+um5WkBtPinv2jQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vernier/godirect": "1.5.0",
|
||||
@@ -12765,7 +12949,7 @@
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
@@ -12999,6 +13183,15 @@
|
||||
"through2": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
@@ -13244,7 +13437,7 @@
|
||||
},
|
||||
"through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
|
||||
"dev": true
|
||||
},
|
||||
@@ -13397,9 +13590,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"transformation-matrix": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-1.14.1.tgz",
|
||||
"integrity": "sha512-zbfndGg//cMW4aytXuF4dkBuUu/SUBA94U2zkmDoRwcplS9hCCn/S3VDp4A7CXw5tVxeCm9F6zdfHzWzei6nXg==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-1.15.0.tgz",
|
||||
"integrity": "sha512-808EqPQbmUD6/IMpWUXLOZcblCHf9xaiB+un0RYNNE9+6VRjoiw6Be8R32tZ0ips1PX/15tlnA2Ev4UUgg827Q==",
|
||||
"dev": true
|
||||
},
|
||||
"transifex": {
|
||||
@@ -14079,6 +14272,15 @@
|
||||
"readable-stream": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"tapable": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
|
||||
@@ -14344,6 +14546,15 @@
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
|
||||
"integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==",
|
||||
"dev": true
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -14702,7 +14913,7 @@
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
|
||||
"integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
@@ -14745,6 +14956,17 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mkdirp": "^0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"write-file-atomic": {
|
||||
|
||||
+9
-9
@@ -44,7 +44,7 @@
|
||||
"babel-loader": "^8.0.4",
|
||||
"base64-loader": "1.0.0",
|
||||
"bowser": "1.9.4",
|
||||
"chromedriver": "78.0.1",
|
||||
"chromedriver": "80.0.0",
|
||||
"classnames": "2.2.6",
|
||||
"computed-style-to-inline-style": "3.0.0",
|
||||
"copy-webpack-plugin": "^4.5.1",
|
||||
@@ -78,9 +78,9 @@
|
||||
"lodash.pick": "4.4.0",
|
||||
"lodash.throttle": "4.0.1",
|
||||
"minilog": "3.1.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"omggif": "1.0.9",
|
||||
"papaparse": "4.6.2",
|
||||
"papaparse": "5.1.1",
|
||||
"postcss-import": "^12.0.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-simple-vars": "^5.0.1",
|
||||
@@ -108,13 +108,13 @@
|
||||
"redux-throttle": "0.1.1",
|
||||
"rimraf": "^2.6.1",
|
||||
"scratch-audio": "0.1.0-prerelease.20190925183642",
|
||||
"scratch-l10n": "3.7.20191219145348",
|
||||
"scratch-blocks": "0.1.0-prerelease.1576850350",
|
||||
"scratch-paint": "0.2.0-prerelease.20191217213717",
|
||||
"scratch-render": "0.1.0-prerelease.20191217212645",
|
||||
"scratch-l10n": "3.7.20200205213636",
|
||||
"scratch-blocks": "0.1.0-prerelease.1580911107",
|
||||
"scratch-paint": "0.2.0-prerelease.20200204235639",
|
||||
"scratch-render": "0.1.0-prerelease.20200128205403",
|
||||
"scratch-storage": "1.3.2",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20191217211338",
|
||||
"scratch-vm": "0.2.0-prerelease.20191119203901",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20200109070519",
|
||||
"scratch-vm": "0.2.0-prerelease.20191227164934",
|
||||
"selenium-webdriver": "3.6.0",
|
||||
"startaudiocontext": "1.2.1",
|
||||
"style-loader": "^0.23.0",
|
||||
|
||||
@@ -92,14 +92,16 @@ class LibraryItem extends React.PureComponent {
|
||||
this.setState({iconIndex: nextIconIndex});
|
||||
}
|
||||
curIconMd5 () {
|
||||
const iconMd5Prop = this.props.iconMd5;
|
||||
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.state.iconIndex < this.props.icons.length) {
|
||||
const icon = this.props.icons[this.state.iconIndex] || {};
|
||||
return icon.md5ext || // 3.0 library format
|
||||
icon.baseLayerMD5 || // 2.0 library format, TODO GH-5084
|
||||
iconMd5Prop;
|
||||
}
|
||||
return this.props.iconMd5;
|
||||
return iconMd5Prop;
|
||||
}
|
||||
render () {
|
||||
const iconMd5 = this.curIconMd5();
|
||||
@@ -151,7 +153,8 @@ LibraryItem.propTypes = {
|
||||
iconRawURL: PropTypes.string,
|
||||
icons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
baseLayerMD5: PropTypes.string
|
||||
baseLayerMD5: PropTypes.string, // 2.0 library format, TODO GH-5084
|
||||
md5ext: PropTypes.string // 3.0 library format
|
||||
})
|
||||
),
|
||||
id: PropTypes.number.isRequired,
|
||||
|
||||
+109
-84
@@ -6,7 +6,12 @@ import VM from 'scratch-vm';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {computeChunkedRMS, encodeAndAddSoundToVM, SOUND_BYTE_LIMIT} from '../lib/audio/audio-util.js';
|
||||
import {
|
||||
computeChunkedRMS,
|
||||
encodeAndAddSoundToVM,
|
||||
downsampleIfNeeded,
|
||||
dropEveryOtherSample
|
||||
} from '../lib/audio/audio-util.js';
|
||||
import AudioEffects from '../lib/audio/audio-effects.js';
|
||||
import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx';
|
||||
import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js';
|
||||
@@ -39,7 +44,8 @@ class SoundEditor extends React.Component {
|
||||
'paste',
|
||||
'handleKeyPress',
|
||||
'handleContainerClick',
|
||||
'setRef'
|
||||
'setRef',
|
||||
'resampleBufferToRate'
|
||||
]);
|
||||
this.state = {
|
||||
copyBuffer: null,
|
||||
@@ -132,45 +138,32 @@ class SoundEditor extends React.Component {
|
||||
});
|
||||
}
|
||||
submitNewSamples (samples, sampleRate, skipUndo) {
|
||||
// Encode the new sound into a wav so that it can be stored
|
||||
let wavBuffer = null;
|
||||
try {
|
||||
wavBuffer = WavEncoder.encode.sync({
|
||||
sampleRate: sampleRate,
|
||||
channelData: [samples]
|
||||
return downsampleIfNeeded({samples, sampleRate}, this.resampleBufferToRate)
|
||||
.then(({samples: newSamples, sampleRate: newSampleRate}) =>
|
||||
WavEncoder.encode({
|
||||
sampleRate: newSampleRate,
|
||||
channelData: [newSamples]
|
||||
}).then(wavBuffer => {
|
||||
if (!skipUndo) {
|
||||
this.redoStack = [];
|
||||
if (this.undoStack.length >= UNDO_STACK_SIZE) {
|
||||
this.undoStack.shift(); // Drop the first element off the array
|
||||
}
|
||||
this.undoStack.push(this.getUndoItem());
|
||||
}
|
||||
this.resetState(newSamples, newSampleRate);
|
||||
this.props.vm.updateSoundBuffer(
|
||||
this.props.soundIndex,
|
||||
this.audioBufferPlayer.buffer,
|
||||
new Uint8Array(wavBuffer));
|
||||
return true; // Edit was successful
|
||||
})
|
||||
)
|
||||
.catch(e => {
|
||||
// Encoding failed, or the sound was too large to save so edit is rejected
|
||||
log.error(`Encountered error while trying to encode sound update: ${e}`);
|
||||
return false; // Edit was not applied
|
||||
});
|
||||
|
||||
if (wavBuffer.byteLength > SOUND_BYTE_LIMIT) {
|
||||
// Cancel the sound update by setting to null
|
||||
wavBuffer = null;
|
||||
log.error(`Refusing to encode sound larger than ${SOUND_BYTE_LIMIT} bytes`);
|
||||
}
|
||||
} catch (e) {
|
||||
// This error state is mostly for the mock sounds used during testing.
|
||||
// Any incorrect sound buffer trying to get interpretd as a Wav file
|
||||
// should yield this error.
|
||||
// This can also happen if the sound is too be allocated in memory.
|
||||
log.error(`Encountered error while trying to encode sound update: ${e}`);
|
||||
}
|
||||
|
||||
// Do not submit sound if it could not be encoded (i.e. if too large)
|
||||
if (wavBuffer) {
|
||||
if (!skipUndo) {
|
||||
this.redoStack = [];
|
||||
if (this.undoStack.length >= UNDO_STACK_SIZE) {
|
||||
this.undoStack.shift(); // Drop the first element off the array
|
||||
}
|
||||
this.undoStack.push(this.getUndoItem());
|
||||
}
|
||||
this.resetState(samples, sampleRate);
|
||||
this.props.vm.updateSoundBuffer(
|
||||
this.props.soundIndex,
|
||||
this.audioBufferPlayer.buffer,
|
||||
new Uint8Array(wavBuffer));
|
||||
|
||||
return true; // Update succeeded
|
||||
}
|
||||
return false; // Update failed
|
||||
}
|
||||
handlePlay () {
|
||||
this.audioBufferPlayer.stop();
|
||||
@@ -209,13 +202,15 @@ class SoundEditor extends React.Component {
|
||||
newSamples.set(firstPart, 0);
|
||||
newSamples.set(secondPart, firstPart.length);
|
||||
}
|
||||
this.submitNewSamples(newSamples, sampleRate);
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
this.submitNewSamples(newSamples, sampleRate).then(() => {
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
});
|
||||
});
|
||||
}
|
||||
handleDeleteInverse () {
|
||||
// Delete everything outside of the trimmers
|
||||
const {samples, sampleRate} = this.copyCurrentBuffer();
|
||||
const sampleCount = samples.length;
|
||||
const startIndex = Math.floor(this.state.trimStart * sampleCount);
|
||||
@@ -224,10 +219,13 @@ class SoundEditor extends React.Component {
|
||||
if (clippedSamples.length === 0) {
|
||||
clippedSamples = new Float32Array(1);
|
||||
}
|
||||
this.submitNewSamples(clippedSamples, sampleRate);
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
this.submitNewSamples(clippedSamples, sampleRate).then(success => {
|
||||
if (success) {
|
||||
this.setState({
|
||||
trimStart: null,
|
||||
trimEnd: null
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
handleUpdateTrim (trimStart, trimEnd) {
|
||||
@@ -257,14 +255,15 @@ class SoundEditor extends React.Component {
|
||||
effects.process((renderedBuffer, adjustedTrimStart, adjustedTrimEnd) => {
|
||||
const samples = renderedBuffer.getChannelData(0);
|
||||
const sampleRate = renderedBuffer.sampleRate;
|
||||
const success = this.submitNewSamples(samples, sampleRate);
|
||||
if (success) {
|
||||
if (this.state.trimStart === null) {
|
||||
this.handlePlay();
|
||||
} else {
|
||||
this.setState({trimStart: adjustedTrimStart, trimEnd: adjustedTrimEnd}, this.handlePlay);
|
||||
this.submitNewSamples(samples, sampleRate).then(success => {
|
||||
if (success) {
|
||||
if (this.state.trimStart === null) {
|
||||
this.handlePlay();
|
||||
} else {
|
||||
this.setState({trimStart: adjustedTrimStart, trimEnd: adjustedTrimEnd}, this.handlePlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
tooLoud () {
|
||||
@@ -287,16 +286,22 @@ class SoundEditor extends React.Component {
|
||||
this.redoStack.push(this.getUndoItem());
|
||||
const {samples, sampleRate, trimStart, trimEnd} = this.undoStack.pop();
|
||||
if (samples) {
|
||||
this.submitNewSamples(samples, sampleRate, true);
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
return this.submitNewSamples(samples, sampleRate, true).then(success => {
|
||||
if (success) {
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleRedo () {
|
||||
const {samples, sampleRate, trimStart, trimEnd} = this.redoStack.pop();
|
||||
if (samples) {
|
||||
this.undoStack.push(this.getUndoItem());
|
||||
this.submitNewSamples(samples, sampleRate, true);
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
return this.submitNewSamples(samples, sampleRate, true).then(success => {
|
||||
if (success) {
|
||||
this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleCopy () {
|
||||
@@ -322,25 +327,39 @@ class SoundEditor extends React.Component {
|
||||
});
|
||||
}
|
||||
resampleBufferToRate (buffer, newRate) {
|
||||
return new Promise(resolve => {
|
||||
if (window.OfflineAudioContext) {
|
||||
const sampleRateRatio = newRate / buffer.sampleRate;
|
||||
const newLength = sampleRateRatio * buffer.samples.length;
|
||||
const offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
|
||||
const source = offlineContext.createBufferSource();
|
||||
const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
|
||||
audioBuffer.getChannelData(0).set(buffer.samples);
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineContext.destination);
|
||||
source.start();
|
||||
offlineContext.startRendering();
|
||||
offlineContext.oncomplete = ({renderedBuffer}) => {
|
||||
resolve({
|
||||
samples: renderedBuffer.getChannelData(0),
|
||||
sampleRate: newRate
|
||||
});
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const sampleRateRatio = newRate / buffer.sampleRate;
|
||||
const newLength = sampleRateRatio * buffer.samples.length;
|
||||
let offlineContext;
|
||||
// Try to use either OfflineAudioContext or webkitOfflineAudioContext to resample
|
||||
// The constructors will throw if trying to resample at an unsupported rate
|
||||
// (e.g. Safari/webkitOAC does not support lower than 44khz).
|
||||
try {
|
||||
if (window.OfflineAudioContext) {
|
||||
offlineContext = new window.OfflineAudioContext(1, newLength, newRate);
|
||||
} else if (window.webkitOfflineAudioContext) {
|
||||
offlineContext = new window.webkitOfflineAudioContext(1, newLength, newRate);
|
||||
}
|
||||
} catch {
|
||||
// If no OAC available and downsampling by 2, downsample by dropping every other sample.
|
||||
if (newRate === buffer.sampleRate / 2) {
|
||||
return resolve(dropEveryOtherSample(buffer));
|
||||
}
|
||||
return reject('Could not resample');
|
||||
}
|
||||
const source = offlineContext.createBufferSource();
|
||||
const audioBuffer = offlineContext.createBuffer(1, buffer.samples.length, buffer.sampleRate);
|
||||
audioBuffer.getChannelData(0).set(buffer.samples);
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineContext.destination);
|
||||
source.start();
|
||||
offlineContext.startRendering();
|
||||
offlineContext.oncomplete = ({renderedBuffer}) => {
|
||||
resolve({
|
||||
samples: renderedBuffer.getChannelData(0),
|
||||
sampleRate: newRate
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
paste () {
|
||||
@@ -351,8 +370,11 @@ class SoundEditor extends React.Component {
|
||||
const newSamples = new Float32Array(newLength);
|
||||
newSamples.set(samples, 0);
|
||||
newSamples.set(this.state.copyBuffer.samples, samples.length);
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false);
|
||||
this.handlePlay();
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
|
||||
if (success) {
|
||||
this.handlePlay();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// else replace the selection with the pasted sound
|
||||
const trimStartSamples = this.state.trimStart * samples.length;
|
||||
@@ -371,11 +393,14 @@ class SoundEditor extends React.Component {
|
||||
const newDurationSeconds = newSamples.length / this.state.copyBuffer.sampleRate;
|
||||
const adjustedTrimStart = trimStartSeconds / newDurationSeconds;
|
||||
const adjustedTrimEnd = trimEndSeconds / newDurationSeconds;
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false);
|
||||
this.setState({
|
||||
trimStart: adjustedTrimStart,
|
||||
trimEnd: adjustedTrimEnd
|
||||
}, this.handlePlay);
|
||||
this.submitNewSamples(newSamples, this.props.sampleRate, false).then(success => {
|
||||
if (success) {
|
||||
this.setState({
|
||||
trimStart: adjustedTrimStart,
|
||||
trimEnd: adjustedTrimEnd
|
||||
}, this.handlePlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handlePaste () {
|
||||
|
||||
@@ -59,9 +59,57 @@ const encodeAndAddSoundToVM = function (vm, samples, sampleRate, name, callback)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@typedef SoundBuffer
|
||||
@type {Object}
|
||||
@property {Float32Array} samples Array of audio samples
|
||||
@property {number} sampleRate Audio sample rate
|
||||
*/
|
||||
|
||||
/**
|
||||
* Downsample the given buffer to try to reduce file size below SOUND_BYTE_LIMIT
|
||||
* @param {SoundBuffer} buffer - Buffer to resample
|
||||
* @param {function(SoundBuffer):Promise<SoundBuffer>} resampler - resampler function
|
||||
* @returns {SoundBuffer} Downsampled buffer with half the sample rate
|
||||
*/
|
||||
const downsampleIfNeeded = (buffer, resampler) => {
|
||||
const {samples, sampleRate} = buffer;
|
||||
const duration = samples.length / sampleRate;
|
||||
const encodedByteLength = samples.length * 2; /* bitDepth 16 bit */
|
||||
// Resolve immediately if already within byte limit
|
||||
if (encodedByteLength < SOUND_BYTE_LIMIT) {
|
||||
return Promise.resolve({samples, sampleRate});
|
||||
}
|
||||
// If encodeable at 22khz, resample and call submitNewSamples again
|
||||
if (duration * 22050 * 2 < SOUND_BYTE_LIMIT) {
|
||||
return resampler({samples, sampleRate}, 22050);
|
||||
}
|
||||
// Cannot save this sound at 22khz, refuse to edit
|
||||
// In the future we could introduce further compression here
|
||||
return Promise.reject('Sound too large to save, refusing to edit');
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop every other sample of an audio buffer as a last-resort way of downsampling.
|
||||
* @param {SoundBuffer} buffer - Buffer to resample
|
||||
* @returns {SoundBuffer} Downsampled buffer with half the sample rate
|
||||
*/
|
||||
const dropEveryOtherSample = buffer => {
|
||||
const newLength = Math.floor(buffer.samples.length / 2);
|
||||
const newSamples = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
newSamples[i] = buffer.samples[i * 2];
|
||||
}
|
||||
return {
|
||||
samples: newSamples,
|
||||
sampleRate: buffer.sampleRate / 2
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
computeRMS,
|
||||
computeChunkedRMS,
|
||||
encodeAndAddSoundToVM,
|
||||
SOUND_BYTE_LIMIT
|
||||
downsampleIfNeeded,
|
||||
dropEveryOtherSample
|
||||
};
|
||||
@@ -54,13 +54,10 @@ const cloudManagerHOC = function (WrappedComponent) {
|
||||
canUseCloud (props) {
|
||||
return !!(props.cloudHost && props.username && props.vm && props.projectId && props.hasCloudPermission);
|
||||
}
|
||||
shouldNotModifyCloudData (props) {
|
||||
return (props.hasEverEnteredEditor && !props.canSave);
|
||||
}
|
||||
shouldConnect (props) {
|
||||
return !this.isConnected() && this.canUseCloud(props) &&
|
||||
props.isShowingWithId && props.vm.runtime.hasCloudData() &&
|
||||
!this.shouldNotModifyCloudData(props);
|
||||
props.canModifyCloudData;
|
||||
}
|
||||
shouldDisconnect (props, prevProps) {
|
||||
return this.isConnected() &&
|
||||
@@ -70,7 +67,7 @@ const cloudManagerHOC = function (WrappedComponent) {
|
||||
(props.projectId !== prevProps.projectId) ||
|
||||
(props.username !== prevProps.username) ||
|
||||
// Editing someone else's project
|
||||
this.shouldNotModifyCloudData(props)
|
||||
!props.canModifyCloudData
|
||||
);
|
||||
}
|
||||
isConnected () {
|
||||
@@ -102,11 +99,11 @@ const cloudManagerHOC = function (WrappedComponent) {
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
canModifyCloudData,
|
||||
cloudHost,
|
||||
projectId,
|
||||
username,
|
||||
hasCloudPermission,
|
||||
hasEverEnteredEditor,
|
||||
isShowingWithId,
|
||||
onShowCloudInfo,
|
||||
/* eslint-enable no-unused-vars */
|
||||
@@ -124,23 +121,30 @@ const cloudManagerHOC = function (WrappedComponent) {
|
||||
}
|
||||
|
||||
CloudManager.propTypes = {
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
canModifyCloudData: PropTypes.bool.isRequired,
|
||||
cloudHost: PropTypes.string,
|
||||
hasCloudPermission: PropTypes.bool,
|
||||
hasEverEnteredEditor: PropTypes.bool,
|
||||
isShowingWithId: PropTypes.bool,
|
||||
isShowingWithId: PropTypes.bool.isRequired,
|
||||
onShowCloudInfo: PropTypes.func,
|
||||
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
username: PropTypes.string,
|
||||
vm: PropTypes.instanceOf(VM).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
CloudManager.defaultProps = {
|
||||
cloudHost: null,
|
||||
hasCloudPermission: false,
|
||||
onShowCloudInfo: () => {},
|
||||
username: null
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loadingState = state.scratchGui.projectState.loadingState;
|
||||
return {
|
||||
hasEverEnteredEditor: state.scratchGui.mode.hasEverEnteredEditor,
|
||||
isShowingWithId: getIsShowingWithId(loadingState),
|
||||
projectId: state.scratchGui.projectState.projectId
|
||||
projectId: state.scratchGui.projectState.projectId,
|
||||
// if you're editing someone else's project, you can't modify cloud data
|
||||
canModifyCloudData: (!state.scratchGui.mode.hasEverEnteredEditor || ownProps.canSave)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
||||
onShowCreatingRemixAlert: () => showAlertWithTimeout(dispatch, 'creatingRemix'),
|
||||
onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'saveSuccess'),
|
||||
onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'),
|
||||
onUpdatedProject: (projectId, loadingState) => dispatch(doneUpdatingProject(projectId, loadingState)),
|
||||
onUpdatedProject: loadingState => dispatch(doneUpdatingProject(loadingState)),
|
||||
setAutoSaveTimeoutId: id => dispatch(setAutoSaveTimeoutId(id))
|
||||
});
|
||||
// Allow incoming props to override redux-provided props. Used to mock in tests.
|
||||
|
||||
@@ -26,7 +26,9 @@ const supportedBrowser = () => {
|
||||
* always returns false
|
||||
*/
|
||||
|
||||
const recommendedBrowser = () => !bowser.isUnsupportedBrowser(minVersions, true);
|
||||
const recommendedBrowser = () =>
|
||||
!bowser.isUnsupportedBrowser(minVersions, true) ||
|
||||
window.navigator.userAgent.toLowerCase().indexOf('googlebot') !== -1;
|
||||
|
||||
export {
|
||||
supportedBrowser as default,
|
||||
|
||||
+14
-1
@@ -3,7 +3,10 @@ import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
import {getIsShowingWithoutId} from '../reducers/project-state';
|
||||
import {
|
||||
getIsAnyCreatingNewState,
|
||||
getIsShowingWithoutId
|
||||
} from '../reducers/project-state';
|
||||
import {setProjectTitle} from '../reducers/project-title';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -27,6 +30,12 @@ const TitledHOC = function (WrappedComponent) {
|
||||
if (this.props.projectTitle !== prevProps.projectTitle) {
|
||||
this.handleReceivedProjectTitle(this.props.projectTitle);
|
||||
}
|
||||
// if project is a new default project, and has loaded,
|
||||
if (this.props.isShowingWithoutId && prevProps.isAnyCreatingNewState) {
|
||||
// reset title to default
|
||||
const defaultProjectTitle = this.handleReceivedProjectTitle();
|
||||
this.props.onUpdateProjectTitle(defaultProjectTitle);
|
||||
}
|
||||
// if the projectTitle hasn't changed, but the reduxProjectTitle
|
||||
// HAS changed, we need to report that change to the projectTitle's owner
|
||||
if (this.props.reduxProjectTitle !== prevProps.reduxProjectTitle &&
|
||||
@@ -40,11 +49,13 @@ const TitledHOC = function (WrappedComponent) {
|
||||
newTitle = this.props.intl.formatMessage(messages.defaultProjectTitle);
|
||||
}
|
||||
this.props.onChangedProjectTitle(newTitle);
|
||||
return newTitle;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
/* eslint-disable no-unused-vars */
|
||||
intl,
|
||||
isAnyCreatingNewState,
|
||||
isShowingWithoutId,
|
||||
onChangedProjectTitle,
|
||||
// for children, we replace onUpdateProjectTitle with our own
|
||||
@@ -66,6 +77,7 @@ const TitledHOC = function (WrappedComponent) {
|
||||
|
||||
TitledComponent.propTypes = {
|
||||
intl: intlShape,
|
||||
isAnyCreatingNewState: PropTypes.bool,
|
||||
isShowingWithoutId: PropTypes.bool,
|
||||
onChangedProjectTitle: PropTypes.func,
|
||||
onUpdateProjectTitle: PropTypes.func,
|
||||
@@ -80,6 +92,7 @@ const TitledHOC = function (WrappedComponent) {
|
||||
const mapStateToProps = state => {
|
||||
const loadingState = state.scratchGui.projectState.loadingState;
|
||||
return {
|
||||
isAnyCreatingNewState: getIsAnyCreatingNewState(loadingState),
|
||||
isShowingWithoutId: getIsShowingWithoutId(loadingState),
|
||||
reduxProjectTitle: state.scratchGui.projectTitle
|
||||
};
|
||||
|
||||
@@ -14,7 +14,10 @@ export default class MockAudioEffects {
|
||||
this.buffer = buffer;
|
||||
this.name = name;
|
||||
this.process = jest.fn(done => {
|
||||
this._finishProcessing = renderedBuffer => done(renderedBuffer, 0, 1);
|
||||
this._finishProcessing = renderedBuffer => {
|
||||
done(renderedBuffer, 0, 1);
|
||||
return new Promise(resolve => setTimeout(resolve));
|
||||
};
|
||||
});
|
||||
MockAudioEffects.instance = this;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class SeleniumHelper {
|
||||
'loadUri',
|
||||
'rightClickText'
|
||||
]);
|
||||
|
||||
this.Key = webdriver.Key; // map Key constants, for sending special keys
|
||||
}
|
||||
|
||||
elementIsVisible (element, timeoutMessage = 'elementIsVisible timed out') {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import path from 'path';
|
||||
import SeleniumHelper from '../helpers/selenium-helper';
|
||||
|
||||
const {
|
||||
clickText,
|
||||
clickXpath,
|
||||
findByXpath,
|
||||
getDriver,
|
||||
Key,
|
||||
loadUri
|
||||
} = new SeleniumHelper();
|
||||
|
||||
const uri = path.resolve(__dirname, '../../build/index.html');
|
||||
|
||||
let driver;
|
||||
|
||||
describe('Project state', () => {
|
||||
beforeAll(() => {
|
||||
driver = getDriver();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await driver.quit();
|
||||
});
|
||||
|
||||
test('File->New resets project title', async () => {
|
||||
const defaultProjectTitle = 'Scratch Project';
|
||||
await loadUri(uri);
|
||||
const inputEl = await findByXpath(`//input[@value="${defaultProjectTitle}"]`);
|
||||
for (let i = 0; i < defaultProjectTitle.length; i++) {
|
||||
inputEl.sendKeys(Key.BACK_SPACE);
|
||||
}
|
||||
inputEl.sendKeys('Changed title of project');
|
||||
await clickText('Costumes'); // just to blur the input
|
||||
// verify that project title has changed
|
||||
await clickXpath('//input[@value="Changed title of project"]');
|
||||
await clickXpath(
|
||||
'//div[contains(@class, "menu-bar_menu-bar-item") and ' +
|
||||
'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
|
||||
);
|
||||
await clickXpath('//li[span[text()="New"]]');
|
||||
// project title should be default again
|
||||
await clickXpath(`//input[@value="${defaultProjectTitle}"]`);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ describe('Sound Editor Container', () => {
|
||||
expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
|
||||
});
|
||||
|
||||
test('it handles an effect by submitting the result and playing', () => {
|
||||
test('it handles an effect by submitting the result and playing', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<SoundEditor
|
||||
soundIndex={soundIndex}
|
||||
@@ -106,7 +106,7 @@ describe('Sound Editor Container', () => {
|
||||
);
|
||||
const component = wrapper.find(SoundEditorComponent);
|
||||
component.props().onReverse(); // Could be any of the effects, just testing the end result
|
||||
mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
await mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
|
||||
expect(vm.updateSoundBuffer).toHaveBeenCalled();
|
||||
});
|
||||
@@ -202,7 +202,7 @@ describe('Sound Editor Container', () => {
|
||||
expect(mockAudioEffects.instance.process).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('undo/redo stack state', () => {
|
||||
test('undo/redo stack state', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<SoundEditor
|
||||
soundIndex={soundIndex}
|
||||
@@ -216,40 +216,40 @@ describe('Sound Editor Container', () => {
|
||||
|
||||
// Submitting new samples should make it possible to undo
|
||||
component.props().onFaster();
|
||||
mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
await mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
expect(component.prop('canUndo')).toEqual(true);
|
||||
expect(component.prop('canRedo')).toEqual(false);
|
||||
|
||||
// Undoing should make it possible to redo and not possible to undo again
|
||||
component.props().onUndo();
|
||||
await component.props().onUndo();
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
expect(component.prop('canUndo')).toEqual(false);
|
||||
expect(component.prop('canRedo')).toEqual(true);
|
||||
|
||||
// Redoing should make it possible to undo and not possible to redo again
|
||||
component.props().onRedo();
|
||||
await component.props().onRedo();
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
expect(component.prop('canUndo')).toEqual(true);
|
||||
expect(component.prop('canRedo')).toEqual(false);
|
||||
|
||||
// New submission should clear the redo stack
|
||||
component.props().onUndo(); // Undo to go back to a state where redo is enabled
|
||||
await component.props().onUndo(); // Undo to go back to a state where redo is enabled
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
expect(component.prop('canRedo')).toEqual(true);
|
||||
component.props().onFaster();
|
||||
mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
await mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
expect(component.prop('canRedo')).toEqual(false);
|
||||
});
|
||||
|
||||
test('undo and redo submit new samples and play the sound', () => {
|
||||
test('undo and redo submit new samples and play the sound', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<SoundEditor
|
||||
soundIndex={soundIndex}
|
||||
@@ -260,12 +260,12 @@ describe('Sound Editor Container', () => {
|
||||
|
||||
// Set up an undoable state
|
||||
component.props().onFaster();
|
||||
mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
await mockAudioEffects.instance._finishProcessing(soundBuffer);
|
||||
wrapper.update();
|
||||
component = wrapper.find(SoundEditorComponent);
|
||||
|
||||
// Undo should update the sound buffer and play the new samples
|
||||
component.props().onUndo();
|
||||
await component.props().onUndo();
|
||||
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
|
||||
expect(vm.updateSoundBuffer).toHaveBeenCalled();
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('Sound Editor Container', () => {
|
||||
mockAudioBufferPlayer.instance.play.mockClear();
|
||||
|
||||
// Undo should update the sound buffer and play the new samples
|
||||
component.props().onRedo();
|
||||
await component.props().onRedo();
|
||||
expect(mockAudioBufferPlayer.instance.play).toHaveBeenCalled();
|
||||
expect(vm.updateSoundBuffer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {computeRMS, computeChunkedRMS} from '../../../src/lib/audio/audio-util';
|
||||
import {
|
||||
computeRMS,
|
||||
computeChunkedRMS,
|
||||
downsampleIfNeeded,
|
||||
dropEveryOtherSample
|
||||
} from '../../../src/lib/audio/audio-util';
|
||||
|
||||
describe('computeRMS', () => {
|
||||
test('returns 0 when given no samples', () => {
|
||||
@@ -49,3 +54,48 @@ describe('computeChunkedRMS', () => {
|
||||
expect(chunkedLevels).toEqual([Math.sqrt(1 / 0.55), Math.sqrt(1 / 0.55)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downsampleIfNeeded', () => {
|
||||
const samples = {length: 1};
|
||||
const sampleRate = 44100;
|
||||
test('returns given data when no downsampling needed', async () => {
|
||||
samples.length = 1;
|
||||
const res = await downsampleIfNeeded({samples, sampleRate}, null);
|
||||
expect(res.samples).toEqual(samples);
|
||||
expect(res.sampleRate).toEqual(sampleRate);
|
||||
});
|
||||
test('downsamples to 22050 if that puts it under the limit', async () => {
|
||||
samples.length = 44100 * 3 * 60;
|
||||
const resampler = jest.fn(() => 'TEST');
|
||||
const res = await downsampleIfNeeded({samples, sampleRate}, resampler);
|
||||
expect(resampler).toHaveBeenCalledWith({samples, sampleRate}, 22050);
|
||||
expect(res).toEqual('TEST');
|
||||
});
|
||||
test('fails if resampling would not put it under the limit', async () => {
|
||||
samples.length = 44100 * 4 * 60;
|
||||
try {
|
||||
await downsampleIfNeeded({samples, sampleRate}, null);
|
||||
} catch (e) {
|
||||
expect(e).toEqual('Sound too large to save, refusing to edit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropEveryOtherSample', () => {
|
||||
const buffer = {
|
||||
samples: [1, 0, 2, 0, 3, 0],
|
||||
sampleRate: 2
|
||||
};
|
||||
test('result is half the length', () => {
|
||||
const {samples} = dropEveryOtherSample(buffer);
|
||||
expect(samples.length).toEqual(Math.floor(buffer.samples.length / 2));
|
||||
});
|
||||
test('result contains only even-index items', () => {
|
||||
const {samples} = dropEveryOtherSample(buffer);
|
||||
expect(samples).toEqual(new Float32Array([1, 2, 3]));
|
||||
});
|
||||
test('result sampleRate is given sampleRate / 2', () => {
|
||||
const {sampleRate} = dropEveryOtherSample(buffer);
|
||||
expect(sampleRate).toEqual(buffer.sampleRate / 2);
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,6 @@ describe('CloudManagerHOC', () => {
|
||||
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -80,7 +79,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
store={store}
|
||||
username="user"
|
||||
@@ -98,7 +96,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -115,7 +112,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
@@ -132,7 +128,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
cloudHost="nonEmpty"
|
||||
hasCloudPermission={false}
|
||||
store={store}
|
||||
@@ -153,7 +148,6 @@ describe('CloudManagerHOC', () => {
|
||||
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
@@ -182,7 +176,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={stillLoadingStore}
|
||||
@@ -209,7 +202,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -235,7 +227,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -262,7 +253,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -293,7 +283,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -315,7 +304,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -343,7 +331,6 @@ describe('CloudManagerHOC', () => {
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -364,12 +351,11 @@ describe('CloudManagerHOC', () => {
|
||||
});
|
||||
|
||||
// Editor Mode Connection/Disconnection Tests
|
||||
test('Entering editor mode and can\'t save project should disconnect cloud provider # 1', () => {
|
||||
test('Entering editor mode and can\'t save project should disconnect cloud provider', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
canSave
|
||||
hasCloudPermission
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
@@ -382,34 +368,7 @@ describe('CloudManagerHOC', () => {
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.setProps({
|
||||
canSave: false,
|
||||
hasEverEnteredEditor: true
|
||||
});
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
expect(vm.setCloudProvider).toHaveBeenCalledWith(null);
|
||||
expect(requestCloseConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Entering editor mode and can\'t save project should disconnect cloud provider # 2', () => {
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = cloudManagerHOC(Component);
|
||||
const mounted = mount(
|
||||
<WrappedComponent
|
||||
hasCloudPermission
|
||||
canSave={false}
|
||||
cloudHost="nonEmpty"
|
||||
store={store}
|
||||
username="user"
|
||||
vm={vm}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(CloudProvider).toHaveBeenCalled();
|
||||
const requestCloseConnection = mockCloudProviderInstance.requestCloseConnection;
|
||||
|
||||
mounted.setProps({
|
||||
hasEverEnteredEditor: true
|
||||
canModifyCloudData: false
|
||||
});
|
||||
|
||||
expect(vm.setCloudProvider.mock.calls.length).toBe(2);
|
||||
|
||||
Reference in new issue
Block a user