Compare commits
403
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34a453f8f | ||
|
|
17e6e59d89 | ||
|
|
568d642e48 | ||
|
|
9532c3dab2 | ||
|
|
7d88656ec5 | ||
|
|
a0abf3ca19 | ||
|
|
d86db2c66a | ||
|
|
c342463415 | ||
|
|
6129c6dd46 | ||
|
|
37beb338da | ||
|
|
7ba07c805e | ||
|
|
6c03b8e034 | ||
|
|
6e6288a2ab | ||
|
|
a9a4b1b79b | ||
|
|
54d7ce2b9b | ||
|
|
c17edd0e05 | ||
|
|
4d22bf270b | ||
|
|
7206495177 | ||
|
|
24323842bf | ||
|
|
3703fa618e | ||
|
|
ff2921c605 | ||
|
|
35399063f3 | ||
|
|
d4a77c6f38 | ||
|
|
d4d794e730 | ||
|
|
85fa229d94 | ||
|
|
6164327b45 | ||
|
|
1a0ee34f0b | ||
|
|
86a1ffde90 | ||
|
|
17088f64b4 | ||
|
|
e7c2abed13 | ||
|
|
719ef3b388 | ||
|
|
e4e9ab5e1b | ||
|
|
4d92e92709 | ||
|
|
4b2222fa3e | ||
|
|
7dfaebd263 | ||
|
|
884843fb82 | ||
|
|
432c448e5c | ||
|
|
a1fa132280 | ||
|
|
ad1af074fc | ||
|
|
952a584d74 | ||
|
|
501caf7793 | ||
|
|
cc22bfc636 | ||
|
|
ed945d392d | ||
|
|
dee7462c57 | ||
|
|
87b076c6a1 | ||
|
|
c966acd987 | ||
|
|
84d000a277 | ||
|
|
fde5ecf24d | ||
|
|
932542e30a | ||
|
|
886e2ebb8f | ||
|
|
d2f120da7e | ||
|
|
45752c2660 | ||
|
|
2f7e9967ab | ||
|
|
fe7a943780 | ||
|
|
637a09d4e9 | ||
|
|
cf52d1ee6c | ||
|
|
2eabf2aefd | ||
|
|
832cf69e5d | ||
|
|
c039845b26 | ||
|
|
f6c586cec6 | ||
|
|
b36b300fc2 | ||
|
|
5c0a0c58e5 | ||
|
|
a64f623c20 | ||
|
|
c688210751 | ||
|
|
c740407aea | ||
|
|
05afad1f73 | ||
|
|
c4676cc5a6 | ||
|
|
f4c09b35e9 | ||
|
|
25136598e3 | ||
|
|
a7d141a83a | ||
|
|
4366b08f94 | ||
|
|
dd43c53db8 | ||
|
|
065e5b6d77 | ||
|
|
636286b427 | ||
|
|
bcbf4259a0 | ||
|
|
b6f4de8796 | ||
|
|
ce7e4d085f | ||
|
|
5910c170f1 | ||
|
|
cfcba71f94 | ||
|
|
3131ece71e | ||
|
|
93525be462 | ||
|
|
9ce52307d2 | ||
|
|
41f27bd860 | ||
|
|
6260f9c093 | ||
|
|
fbff6843f4 | ||
|
|
80662eb585 | ||
|
|
dd90ec866c | ||
|
|
893ab2c406 | ||
|
|
d25afb7c0e | ||
|
|
86a05f55f3 | ||
|
|
27c352196d | ||
|
|
8e9a17b4e7 | ||
|
|
3e2686cd67 | ||
|
|
a8595edf8f | ||
|
|
1c2530ae09 | ||
|
|
527cc0b0fb | ||
|
|
78143a1b35 | ||
|
|
180192ae15 | ||
|
|
e74bba9257 | ||
|
|
55b3584799 | ||
|
|
773a2e8672 | ||
|
|
083bb169d4 | ||
|
|
eb180f6f03 | ||
|
|
98fff1e044 | ||
|
|
45de3567db | ||
|
|
6320ae3a99 | ||
|
|
24957f3de4 | ||
|
|
22bd7219d7 | ||
|
|
aeb8238ecb | ||
|
|
bab0e4fd5c | ||
|
|
f425b27cf6 | ||
|
|
95ef21d3ad | ||
|
|
9aba9ec3fb | ||
|
|
3e4cf00eca | ||
|
|
a8c5083aa3 | ||
|
|
a1e72203c8 | ||
|
|
cc8f39e590 | ||
|
|
6f7962abd4 | ||
|
|
dcf92b2455 | ||
|
|
7a1db103ba | ||
|
|
394f2c8c16 | ||
|
|
bbd95f4672 | ||
|
|
5378c57537 | ||
|
|
a93e9df129 | ||
|
|
221978b24e | ||
|
|
333e95c898 | ||
|
|
953702e238 | ||
|
|
fb3b1ced7c | ||
|
|
17ae4ea197 | ||
|
|
16d15bd4f1 | ||
|
|
a6b5c237cc | ||
|
|
10d014a7ff | ||
|
|
e0019c93fc | ||
|
|
00e8b3cdb2 | ||
|
|
f7853a9354 | ||
|
|
d972629c29 | ||
|
|
9d27a1af23 | ||
|
|
98ebd453d7 | ||
|
|
63b5a9a059 | ||
|
|
8ea62f5ea5 | ||
|
|
bc2152641b | ||
|
|
9883e0efb6 | ||
|
|
5a614a1041 | ||
|
|
ebf369e293 | ||
|
|
b6a63a55ad | ||
|
|
78fe4c921c | ||
|
|
953a42d35e | ||
|
|
584bfeee5e | ||
|
|
f7d5c1898e | ||
|
|
77c5bc7254 | ||
|
|
a150efc743 | ||
|
|
8b53d1ed57 | ||
|
|
8e4c76f74f | ||
|
|
9053277889 | ||
|
|
6baf5c0e48 | ||
|
|
98c4e4df65 | ||
|
|
08160e588a | ||
|
|
fc998b9779 | ||
|
|
d0eb28f11c | ||
|
|
4f7fd39f5e | ||
|
|
3aed7007e8 | ||
|
|
731f3bbf5d | ||
|
|
715e46fe1c | ||
|
|
cc68eef196 | ||
|
|
550e236f48 | ||
|
|
40415e2359 | ||
|
|
d20b68c939 | ||
|
|
6af3fda54a | ||
|
|
43191504ff | ||
|
|
3008feb1de | ||
|
|
78c2aea407 | ||
|
|
b41b1c11bb | ||
|
|
8e82c2d1e3 | ||
|
|
a75e2b0ecf | ||
|
|
a731370478 | ||
|
|
f3ced9c9ea | ||
|
|
dee0320164 | ||
|
|
942b8d6e6a | ||
|
|
c296732753 | ||
|
|
212ffb42d3 | ||
|
|
af0088df88 | ||
|
|
63bdc80388 | ||
|
|
a27ddaf4ad | ||
|
|
5ee641e70f | ||
|
|
e9d16dcd2a | ||
|
|
d156a3d995 | ||
|
|
1019726bcf | ||
|
|
3fbb7fe8d4 | ||
|
|
124a6496f5 | ||
|
|
2e11787ac5 | ||
|
|
53bdab3425 | ||
|
|
058fd000fa | ||
|
|
dc727f1b57 | ||
|
|
bab503addb | ||
|
|
662726fa52 | ||
|
|
a0b4aea8a1 | ||
|
|
b180dd4c0d | ||
|
|
02cdbf68cd | ||
|
|
f1f1f54ec6 | ||
|
|
256bb87858 | ||
|
|
bdbf99f606 | ||
|
|
8e3603aa8b | ||
|
|
a5baf0cf5f | ||
|
|
7912e4306c | ||
|
|
5fa8beb230 | ||
|
|
9b4b47367b | ||
|
|
f69f4bfe2b | ||
|
|
306497c095 | ||
|
|
17cff4775c | ||
|
|
2ee25bcc0f | ||
|
|
7ed3a6b7c8 | ||
|
|
73fad0b7f5 | ||
|
|
147a4ed586 | ||
|
|
b1a3c4b446 | ||
|
|
4563b41429 | ||
|
|
0c8b982479 | ||
|
|
e5811f15be | ||
|
|
a4624da5f2 | ||
|
|
89a748dd2b | ||
|
|
5cccd63f6c | ||
|
|
33e01c1bdd | ||
|
|
8c6b52f43c | ||
|
|
2ebcd850d6 | ||
|
|
9d307f0e08 | ||
|
|
fde7349b2b | ||
|
|
93406fbef7 | ||
|
|
d7cb0126ad | ||
|
|
41831eede7 | ||
|
|
923b931d90 | ||
|
|
600172f8fb | ||
|
|
41a50b87ab | ||
|
|
d99360e021 | ||
|
|
4e4d359d18 | ||
|
|
92f05792f9 | ||
|
|
cd9d25ad48 | ||
|
|
e34d5ed67e | ||
|
|
af4ad81811 | ||
|
|
c368de81fa | ||
|
|
0104f5bec8 | ||
|
|
8690b696a5 | ||
|
|
ae8624e7e7 | ||
|
|
7f8b84829e | ||
|
|
d4078a795f | ||
|
|
f381a6a0d8 | ||
|
|
962ce680c9 | ||
|
|
630ba84350 | ||
|
|
51843d6bef | ||
|
|
02be2155d9 | ||
|
|
cca695b54e | ||
|
|
4e633cb596 | ||
|
|
8dc343c90d | ||
|
|
369eb8c3cf | ||
|
|
a7021e32a4 | ||
|
|
863276e75d | ||
|
|
de9dbe08f4 | ||
|
|
c050829a75 | ||
|
|
6e57ea1c91 | ||
|
|
53d09e2162 | ||
|
|
5d9afb008a | ||
|
|
d19cf91205 | ||
|
|
4f70b0f924 | ||
|
|
f8a0f46959 | ||
|
|
faf0dd2f13 | ||
|
|
cbb908831d | ||
|
|
b44c0c355a | ||
|
|
ffa364ed64 | ||
|
|
017de39c3b | ||
|
|
01c7da1965 | ||
|
|
59f428900c | ||
|
|
48245be395 | ||
|
|
ca38789e3f | ||
|
|
efed4d3721 | ||
|
|
95532e147d | ||
|
|
3a56e609a5 | ||
|
|
e63842a336 | ||
|
|
c9173427b9 | ||
|
|
3e5b65072c | ||
|
|
74828b3b09 | ||
|
|
e77162791f | ||
|
|
0c17cd956d | ||
|
|
287b29019a | ||
|
|
ed3b58bfb5 | ||
|
|
c2cd6f21da | ||
|
|
066e9dd6bd | ||
|
|
5a0860228e | ||
|
|
f2291d963c | ||
|
|
97885c7518 | ||
|
|
3a8fe3f1b2 | ||
|
|
a23dd3e349 | ||
|
|
79f3f6f3f8 | ||
|
|
175395cce3 | ||
|
|
ee67ddf56f | ||
|
|
5c259b70db | ||
|
|
3dd86c28f9 | ||
|
|
4b75bc8f9d | ||
|
|
85cd56307d | ||
|
|
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 | ||
|
|
a4e27112ab | ||
|
|
50265fe843 | ||
|
|
461877b733 | ||
|
|
2c16a16da5 | ||
|
|
ed0db53f36 | ||
|
|
f244da7705 | ||
|
|
3d1205e453 | ||
|
|
3b1889b941 | ||
|
|
a908345491 | ||
|
|
50de01086a | ||
|
|
57863d99a1 | ||
|
|
3e93db7c0b | ||
|
|
28f4cc737d | ||
|
|
fa5cfb495f | ||
|
|
5b2e8a29e3 | ||
|
|
a7fce92b18 | ||
|
|
12b8cfbb43 | ||
|
|
d1d98337c8 | ||
|
|
480f17d872 | ||
|
|
274cbbd356 | ||
|
|
bb80fa49ac | ||
|
|
38e4c5bbfa | ||
|
|
b3e5dd88fb | ||
|
|
6027bb9011 | ||
|
|
d62ede33e5 | ||
|
|
1a479c61b4 | ||
|
|
2e8adc7ac7 | ||
|
|
5c377e70c9 | ||
|
|
67fdd9bb51 | ||
|
|
c68ec4ad16 | ||
|
|
91e87f7d16 | ||
|
|
762ffe53f0 | ||
|
|
d63c8b1dbb | ||
|
|
e799708707 | ||
|
|
b699e76972 | ||
|
|
914ba4910b | ||
|
|
748f8559e4 | ||
|
|
8fae109811 | ||
|
|
7980c23ecc | ||
|
|
5e4a2b788c | ||
|
|
8caff78e08 | ||
|
|
e1c3bb6d96 | ||
|
|
aebdcc855c | ||
|
|
86fd6ec8bf | ||
|
|
b351cc599d | ||
|
|
0230546a20 | ||
|
|
9638444a49 | ||
|
|
984b87ead5 | ||
|
|
59c4f36b90 | ||
|
|
21a6fe3a27 | ||
|
|
02f806fc9d | ||
|
|
d9003c9d56 | ||
|
|
c944295140 | ||
|
|
2c64a62f23 | ||
|
|
8639f1838c | ||
|
|
4e80510b98 | ||
|
|
9f58bd9ee3 | ||
|
|
236cc6f872 | ||
|
|
b3d336a08f | ||
|
|
97fcbf85fc | ||
|
|
e3b3bcc963 | ||
|
|
775b805e56 | ||
|
|
a1124807ca | ||
|
|
b1674c1b0c | ||
|
|
21d6ce111e | ||
|
|
fcb6de39f3 | ||
|
|
3c6e9723e3 | ||
|
|
9dcaa0985f | ||
|
|
2429f63e02 | ||
|
|
28cda95346 | ||
|
|
42375da2e1 | ||
|
|
79fa9bef65 |
No files matched your search
@@ -12,6 +12,7 @@
|
||||
*.svg binary
|
||||
|
||||
# Prefer LF for most file types
|
||||
*.css text eol=lf
|
||||
*.frag text eol=lf
|
||||
*.htm text eol=lf
|
||||
*.html text eol=lf
|
||||
|
||||
+1
-1
@@ -14,6 +14,6 @@ npm-*
|
||||
/dist
|
||||
/.opt-in
|
||||
|
||||
# generated translation files
|
||||
# Generated translation files
|
||||
/translations
|
||||
/locale
|
||||
+1
-1
@@ -4,7 +4,7 @@ dist: xenial
|
||||
addons:
|
||||
chrome: stable
|
||||
node_js:
|
||||
- 8
|
||||
- 10
|
||||
env:
|
||||
global:
|
||||
- CHROMEDRIVER_VERSION=LATEST
|
||||
|
||||
@@ -18,6 +18,8 @@ cd scratch-gui
|
||||
npm install
|
||||
```
|
||||
|
||||
**You may want to add `--depth=1` to the `git clone` command because there are some [large files in the git repository history](https://github.com/LLK/scratch-gui/issues/5140).**
|
||||
|
||||
## Getting started
|
||||
Running the project requires Node.js to be installed.
|
||||
|
||||
@@ -74,7 +76,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 +196,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">7V1Zd6O4Ev41OffJHImdRydxujOT7XSSXuYlh9g4YcY2GUw6yfz6KxYBEqtBErjbeYkthMCo9FXVV6XiSDlZv3/y7ZfnS2/hrI5ksHg/Uk6PZBmqEKJ/YctH0gKBFrc8+e4iacsabt3/nKQRJK2v7sLZEh0Dz1sF7gvZOPc2G2ceEG2273tvZLeltyKv+mI/JVcEWcPt3F45hW7f3EXwnP6MXPfPjvv0nFza1JIDj/b8nyffe90k1zuSlWX0Fx9e23ispP/22V54b7kmZXaknPieF8Sf1u8nzip8uPixxeedVRxN79t3NkGbExRTfrTgo6ZB+LgAS2OSjPDTXr06+CdENxp84IeDRkDzgL4cPwfrFWqD6CP6IS/h8W1g+8FtYAfh8aW7Wp14K8+PTlRA9Bd2DnzvHyd3ZLlMjkSPzllkg8ZPJzw099buPPm8sh+d1XH6rPFIG28TXdbbBGf22l2F4vfV8Rf2xk6aE1GDcvK97O7slfu0QW1z9AwddPB44fpIyFwvbNx6r+EUHhcfNH5yjh8477mm5MF/cry1E/gfqEtyVMeLIlklE6gnUvGWyZySND3npA232YmUP6VDZzONPiSTXT7x/z3CCw98/uv8z5/G2fftuxxsLieqWZzsBVoYyVfPD569J29jr2ZZ6/H81f+ZzljW5cLzXpLGv50g+EievP0aeKTkOO9u8D2cV0lLvv3IHTl9T6Y8+vKBv2zQz82dFH79kT+WnRZ9w+dVzhqaVn/uVKyJZJqQZD85Qc3jS55e+Mhq5993Vnbg/iSxps9knn9Tzq//Mh+Xp856onvw6uz934lSspL1VZAIPjHL+r+vHj4w2UYTNUUdIHh5zw6iT0/h/9Prq9nDxfX09Pzq08PXy4dv53efH85P8eDoXuPx494FgUKLIyAFoLDewiXkIjCeJgfW7mIRiZrvoJuzH6Ohwul88dxNED037fhIO60GhgyG8R0VlmmqTJLx87ohE5eaZVO5zidAUlVFjQdrPffJcDfhT8zGope+t1xukUzSspLeRCvxqRL4StHBUxu3bV/sTStxgmXiVCtJ8dDk5R59uqWtPFtl11959mIbzrLv/Y1m/9QO7PDRbBBMoSewrhTrqptpkn9Sw709u4Fz+2JH2POGrClqafjzBDdltahPQ4Ge1+vTkWhQBtoyXY4f1PecskwVaF5b6ry0JSzMbbOy7KUfIdDzGlICWGGyVZKEQg9HvHF8Fz2ycCZ7aFG9qEULfQzWGjQ6der79keuQ6I2KkFW0wxC1CBlQxcwGdR0Rx/i63dF51LZM3RG2j2eT7ByN84Er5roaLi8tCJc3s7uHm6+XP8xO7nLQXUGg28uWowpmp4vwivIur0OgS0BxPBxdUXPkVgPjSZBFbxVWRl1xoNpQtJPMJiYEhMKUHUOlkXps1E64CapNQV5GcxwrvQxyEN5C+WAUub4jxVQ0E2E97HXQIJM27m7eUKtentYwT5db1hBLommkvSDzAZVVBKrhKGKJsgaY2VUxWOYxg6kB09TrNyx5WKOFWTG1EmhgbpGDhHfaHJWJjq72nW6SbkQ9XadpdZ152TXyYxguACzd9Mvd3na5uz8YvZwfxO27A1mtkRJTF+wML5kA1DCyQYmddLFmMgWB6CsWvjMpevqOpatWWtZOtAg3WgQjQKlSRqTGowHMfbFnhcbNTCKSraKaB1JxICX7okiBmezu5PPoe6hWN7MdEd4VyBi90oxsYwpGE2KSVGxrZAiQT/NxFfzGA2yxTKkUCVqYgIKTyViDJa+tw5HdJAX4B9iCiNRpnRMQbMGVqVQeEyhk/ZUCO0pAWjs5P2Gh9g4sjhfqNGTxR25u7IK6S0otF1f4cp2gNhSFW7ygdnWiQDX93ddALffjZCGQ0oR5vjB1jfidlUFZtmNIRNi4wVJD3SXQUj4kTf67PhOOKnbzf/C6yBjwdlubSQDWrUrlTYX7nYPeVD2phOsB31kO2mqbpJOPWDi1dNxGgnIVv5PhIuPfwpzKz630q9m3x5OZ2fT+4u7/mu6Z1ZGu3V9sKDE0BFCszJkW3mcyxqw50ugQ2MxsTpYT7l5rX/6zmYxDVOLw5FenE3ckjx3s3a6W84bnp+VswzqZqfeImrBOuhDsQ6lcK13mLTxhoPrQr21k8LeQu3FBbEKB9dzQZVKpJoLOpg4eM3UWjgQ9qSDsEvMwWapWyNCPJdU/koMGaEk0cJZ2q+roCXreTBuhjVuBqeHtC700C+3P6MuVWEAIqhf7Kwp1Z4fOVOlbMv4k71Vus25AiwzNTUyIiP308BpqgBJK1ADMNHI5eHAIXeDwRzWAMmoz3QvXdI9cIQdLYxza4FMnsIoowlCi/K/k9Spqpymwgl452rhhPLjvbOeyiXNEoSCd9cPt9Ovsz0DsY6eQ82irgMxS4MWISITyAbIdIlKeoKKKOdCrZSvfo5EYz6dAFeCySAU3xq6JhTlKu3slrCNpVylgRSSE/Yekxt2w/vfOFF8HURyJCB8cnCturlWpj4gb1zuGHThIflYO2239RGeVeZoMfetzJa+lczatepkE6kGKVpqw/Y9ur9p8Td4qvXRXm22QVAhowcA4uTyPKu2944j04B0I11r6NhZxHkjChOTS5MlqsaIbIiyubQu+/x+D7KqNaCOq6IIbEri7Lc55f7mdHoX4sfx7Oz6y+zh5Mss/n5yffPjN4aPeCFVwgeSWquUIerJWfDgmcqDWYOy2oTlBRWlHVRACQCTtL+Sc2sAo2wDX/dkyF7Igl38sSALK4OoHFkup1f304sUYPYGS7I9wnJ6gucvHD/X+Sz64wI8emMiHTAU0m7BPkBfzlshRwWSZYL8nyoMnIa1YgAgXUNdV9vaMqZMARQAewRQeMCxAFR1nK4fgxgD1PT+7roMnkj6cGzwJB6Q6i2hsNCabJDQwahWClQhWlC5VF4yf1hXJCis0oHepdQBO0gKK8VkkBQqgYg9bGM07UZYda530M8yYl6Dqh/wVJdxZAE8X2aX598PmNO42uq8L6iRkMOm0qMmqSTGTFS00oRhTJdN3b0xJjNeZDZMTLOZwdwPKp9NhdppqKpUHCPGr94JAfR1tAbyWzNr+/Nhv3E8WTSPlOOlR+/6cQCyxi3lumlSLLTOBMp0XbLIyL/Ox1wqlbYucT1moTy5ZSyPMo2MJtuIgNpSCGNmJZVv5Rce1VNIoFKs+kQnuj9MKAOuwMaNH2dZdPPXhr5qCKh1Gy1QWvK1fzU8HvW6y5UqKMykSK/QVOUc8IX0eEuiikI+ubtXyAsA+eWJ7mzdaWQ0VwP11h1M5bjrCeiSAuxBXpvNfqGidyzhEDaXHFYBmQPKiEfT0AKnCoRaEhCWBtpUHaNrwaHbz9ffyusNHZIHBZTtUbATO9zGrC7Kt4e+jVjXPA2LlqzSkoUFkmVRBXySc2u0biaqlTGhneha3nyLphiSSiKNKsuSBXMMPrW1tYKBKSphw5TMXDRS2e0yjHZ+aJR/o8pK7V039Oe0r4NXLZJoX0cZr1O3qW1vMxKFc95ac1lcgHc0Mao+iIkhUaZA044jlvUIKwhIAXtDts6mfFtHtKkDVyM8KV6dLGIYP8zDtvRxmD/0tvThzR+1S+GdvuYPnRhj1Js/JdYJadRUzkwjZyCquKCB43+pDqcmlJFtUXUdvrYCr53wMQmwl0Eg4Yo/XsiVyEPVt4Ogp9LHm8Go/aZAMpR82TwML/ytAmPYbD/iZSCgHs8yd84gYuUpn8p810LrYqsqazjsV2KDb27xL8EuNpcjZ/jmIRz6ZeQ54CAzRViaHDCjXHENsnG0R05dcdtDQ5S6dDmzwxA+AeailqFSXRSDsp4YpeAYNDvZEKku9K+qyJFy6P36i9jfKh8i4eMM/cjNW1Flk0xnZAPPikZX/5hwKf9R/ir0YeumQbmDXbdrRhDnuHhr409mAd67Ym4B2xsw0TD69ReSTYS3VnFK/S7brj/69G/2iKjUk21o9aoGm+A3lVqhwBQR+Tu2LKrCN4a+Cy9/ONC/AkrnDE//6uMpndNQKDDHmuS2a7fZYsB4GyQseTtcXcrKSHYjydVcCoN3yTRuRsqs/a7XYXO3VbHl0atP8bunGv0NXQEUH6SxcTiIQXmwQbULu88KKVW0OywOsmXh/mxzVX61BXcMQlfEl1Fz9EtG9uN2D5TTP+RgFjExi7IX4WIGePCSgro8pF2EX8uX2kZKhxB5DwtH0M5KOhlUbVlqmRnVWb1Bm0GO296VrBFvYVQQTpmFAS2TTNHsyWgKMCKqs9P6GRFxIaQGE6IfIVAQWcZvhmtnTRxeDzdOpTwCskIdUCkDyQL5WECYyAa6lfs1x1BurnWdhZHRGNXMOgedvYcZ5xzUdMWGyVzgUaNSOLDQ9CUCoCQT42qSQtSSE1ZUJcVP1mo9LB92UOoLine truncated
|
||||
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Generated
+3880
-1154
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "npm run clean && webpack --progress --colors --bail",
|
||||
"clean": "rimraf ./build && mkdirp build && rimraf ./dist && mkdirp dist",
|
||||
"deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1)\"",
|
||||
"deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1) [skip ci]\"",
|
||||
"prune": "./prune-gh-pages.sh",
|
||||
"i18n:push": "tx-push-src scratch-editor interface translations/en.json",
|
||||
"i18n:src": "rimraf ./translations/messages/src && babel src > tmp.js && rimraf tmp.js && build-i18n-src ./translations/messages/src ./translations/ && npm run i18n:push",
|
||||
@@ -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",
|
||||
@@ -103,19 +103,18 @@
|
||||
"react-test-renderer": "16.2.0",
|
||||
"react-tooltip": "3.8.0",
|
||||
"react-virtualized": "9.20.1",
|
||||
"react-visibility-sensor": "5.0.2",
|
||||
"redux": "3.7.2",
|
||||
"redux-mock-store": "^1.2.3",
|
||||
"redux-throttle": "0.1.1",
|
||||
"rimraf": "^2.6.1",
|
||||
"scratch-audio": "0.1.0-prerelease.20190925183642",
|
||||
"scratch-l10n": "3.6.20191120161253",
|
||||
"scratch-blocks": "0.1.0-prerelease.1574180945",
|
||||
"scratch-paint": "0.2.0-prerelease.20191114200704",
|
||||
"scratch-render": "0.1.0-prerelease.20191106204814",
|
||||
"scratch-storage": "1.3.2",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20191104164753",
|
||||
"scratch-vm": "0.2.0-prerelease.20191119203901",
|
||||
"scratch-audio": "0.1.0-prerelease.20200528195344",
|
||||
"scratch-blocks": "0.1.0-prerelease.20200528193139",
|
||||
"scratch-l10n": "3.9.20200528181319",
|
||||
"scratch-paint": "0.2.0-prerelease.20200529184518",
|
||||
"scratch-render": "0.1.0-prerelease.20200512203149",
|
||||
"scratch-storage": "1.3.3",
|
||||
"scratch-svg-renderer": "0.2.0-prerelease.20200507183648",
|
||||
"scratch-vm": "0.2.0-prerelease.20200527195112",
|
||||
"selenium-webdriver": "3.6.0",
|
||||
"startaudiocontext": "1.2.1",
|
||||
"style-loader": "^0.23.0",
|
||||
@@ -140,7 +139,8 @@
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/__mocks__/fileMock.js",
|
||||
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
|
||||
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
|
||||
"editor-msgs(\\.js)?$": "<rootDir>/test/__mocks__/editor-msgs-mock.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
padding-right: 1rem;
|
||||
margin-bottom: 7px;
|
||||
min-height: 1.5rem;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.alert.warn {
|
||||
|
||||
@@ -309,6 +309,7 @@ $fade-out-distance: 15px;
|
||||
z-index: $z-index-alerts;
|
||||
position: absolute;
|
||||
margin-top: 53px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -89,6 +89,7 @@ const GUIComponent = props => {
|
||||
loading,
|
||||
logo,
|
||||
renderLogin,
|
||||
onClickAbout,
|
||||
onClickAccountNav,
|
||||
onCloseAccountNav,
|
||||
onLogOut,
|
||||
@@ -105,6 +106,7 @@ const GUIComponent = props => {
|
||||
onRequestCloseTelemetryModal,
|
||||
onSeeCommunity,
|
||||
onShare,
|
||||
onShowPrivacyPolicy,
|
||||
onTelemetryModalCancel,
|
||||
onTelemetryModalOptIn,
|
||||
onTelemetryModalOptOut,
|
||||
@@ -162,6 +164,7 @@ const GUIComponent = props => {
|
||||
onOptIn={onTelemetryModalOptIn}
|
||||
onOptOut={onTelemetryModalOptOut}
|
||||
onRequestClose={onRequestCloseTelemetryModal}
|
||||
onShowPrivacyPolicy={onShowPrivacyPolicy}
|
||||
/>
|
||||
) : null}
|
||||
{loading ? (
|
||||
@@ -218,6 +221,7 @@ const GUIComponent = props => {
|
||||
logo={logo}
|
||||
renderLogin={renderLogin}
|
||||
showComingSoon={showComingSoon}
|
||||
onClickAbout={onClickAbout}
|
||||
onClickAccountNav={onClickAccountNav}
|
||||
onClickLogo={onClickLogo}
|
||||
onCloseAccountNav={onCloseAccountNav}
|
||||
@@ -388,6 +392,7 @@ GUIComponent.propTypes = {
|
||||
onActivateCostumesTab: PropTypes.func,
|
||||
onActivateSoundsTab: PropTypes.func,
|
||||
onActivateTab: PropTypes.func,
|
||||
onClickAbout: PropTypes.func,
|
||||
onClickAccountNav: PropTypes.func,
|
||||
onClickLogo: PropTypes.func,
|
||||
onCloseAccountNav: PropTypes.func,
|
||||
@@ -399,6 +404,7 @@ GUIComponent.propTypes = {
|
||||
onRequestCloseTelemetryModal: PropTypes.func,
|
||||
onSeeCommunity: PropTypes.func,
|
||||
onShare: PropTypes.func,
|
||||
onShowPrivacyPolicy: PropTypes.func,
|
||||
onTabSelect: PropTypes.func,
|
||||
onTelemetryModalCancel: PropTypes.func,
|
||||
onTelemetryModalOptIn: PropTypes.func,
|
||||
|
||||
@@ -4,7 +4,6 @@ import React from 'react';
|
||||
|
||||
import Box from '../box/box.jsx';
|
||||
import PlayButton from '../../containers/play-button.jsx';
|
||||
import ScratchImage from '../scratch-image/scratch-image.jsx';
|
||||
import styles from './library-item.css';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -37,12 +36,10 @@ class LibraryItemComponent extends React.PureComponent {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{this.props.iconSource ? (
|
||||
<ScratchImage
|
||||
className={styles.featuredImage}
|
||||
imageSource={this.props.iconSource}
|
||||
/>
|
||||
) : null}
|
||||
<img
|
||||
className={styles.featuredImage}
|
||||
src={this.props.iconURL}
|
||||
/>
|
||||
</div>
|
||||
{this.props.insetIconURL ? (
|
||||
<div className={styles.libraryItemInsetImageContainer}>
|
||||
@@ -130,9 +127,9 @@ class LibraryItemComponent extends React.PureComponent {
|
||||
onMouseEnter={this.props.showPlayButton ? this.props.onMouseEnter : null}
|
||||
onMouseLeave={this.props.showPlayButton ? this.props.onMouseLeave : null}
|
||||
>
|
||||
<ScratchImage
|
||||
<img
|
||||
className={styles.libraryItemImage}
|
||||
imageSource={this.props.iconSource}
|
||||
src={this.props.iconURL}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -162,7 +159,7 @@ LibraryItemComponent.propTypes = {
|
||||
extensionId: PropTypes.string,
|
||||
featured: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
iconSource: ScratchImage.ImageSourcePropType,
|
||||
iconURL: PropTypes.string,
|
||||
insetIconURL: PropTypes.string,
|
||||
internetConnectionRequired: PropTypes.bool,
|
||||
isPlaying: PropTypes.bool,
|
||||
|
||||
@@ -10,7 +10,6 @@ import Divider from '../divider/divider.jsx';
|
||||
import Filter from '../filter/filter.jsx';
|
||||
import TagButton from '../../containers/tag-button.jsx';
|
||||
import Spinner from '../spinner/spinner.jsx';
|
||||
import storage from '../../lib/storage';
|
||||
|
||||
import styles from './library.css';
|
||||
|
||||
@@ -30,47 +29,6 @@ const messages = defineMessages({
|
||||
const ALL_TAG = {tag: 'all', intlLabel: messages.allTag};
|
||||
const tagListPrefix = [ALL_TAG];
|
||||
|
||||
/**
|
||||
* Find the AssetType which corresponds to a particular file extension. For example, 'png' => AssetType.ImageBitmap.
|
||||
* @param {string} fileExtension - the file extension to look up.
|
||||
* @returns {AssetType} - the AssetType corresponding to the extension, if any.
|
||||
*/
|
||||
const getAssetTypeForFileExtension = function (fileExtension) {
|
||||
const compareOptions = {
|
||||
sensitivity: 'accent',
|
||||
usage: 'search'
|
||||
};
|
||||
for (const assetTypeId in storage.AssetType) {
|
||||
const assetType = storage.AssetType[assetTypeId];
|
||||
if (fileExtension.localeCompare(assetType.runtimeFormat, compareOptions) === 0) {
|
||||
return assetType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Figure out an `imageSource` (URI or asset ID & type) for a library item's icon.
|
||||
* @param {object} item - either a library item or one of a library item's costumes.
|
||||
* @returns {object} - an `imageSource` ready to be passed to a `ScratchImage`.
|
||||
*/
|
||||
const getItemImageSource = function (item) {
|
||||
if (item.rawURL) {
|
||||
return {
|
||||
uri: item.rawURL
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: adjust libraries to be more storage-friendly; don't use split() here.
|
||||
const md5 = item.md5 || item.baseLayerMD5;
|
||||
if (md5) {
|
||||
const [assetId, fileExtension] = md5.split('.');
|
||||
return {
|
||||
assetId: assetId,
|
||||
assetType: getAssetTypeForFileExtension(fileExtension)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
class LibraryComponent extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
@@ -250,10 +208,8 @@ class LibraryComponent extends React.Component {
|
||||
})}
|
||||
ref={this.setFilteredDataRef}
|
||||
>
|
||||
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => {
|
||||
const iconSource = getItemImageSource(dataItem);
|
||||
const icons = dataItem.json && dataItem.json.costumes.map(getItemImageSource);
|
||||
return (<LibraryItem
|
||||
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => (
|
||||
<LibraryItem
|
||||
bluetoothRequired={dataItem.bluetoothRequired}
|
||||
collaborator={dataItem.collaborator}
|
||||
description={dataItem.description}
|
||||
@@ -261,8 +217,9 @@ class LibraryComponent extends React.Component {
|
||||
extensionId={dataItem.extensionId}
|
||||
featured={dataItem.featured}
|
||||
hidden={dataItem.hidden}
|
||||
iconSource={iconSource}
|
||||
icons={icons}
|
||||
iconMd5={dataItem.md5}
|
||||
iconRawURL={dataItem.rawURL}
|
||||
icons={dataItem.json && dataItem.json.costumes}
|
||||
id={index}
|
||||
insetIconURL={dataItem.insetIconURL}
|
||||
internetConnectionRequired={dataItem.internetConnectionRequired}
|
||||
@@ -273,8 +230,8 @@ class LibraryComponent extends React.Component {
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onSelect={this.handleSelect}
|
||||
/>);
|
||||
}) : (
|
||||
/>
|
||||
)) : (
|
||||
<div className={styles.spinnerWrapper}>
|
||||
<Spinner
|
||||
large
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path d="M10 2c4.418 0 8 3.582 8 8s-3.582 8-8 8-8-3.582-8-8 3.582-8 8-8zm-.072 10.42c-.256 0-.472.076-.648.227-.176.151-.264.338-.264.56 0 .25.091.445.273.584.182.139.395.209.639.209.236 0 .444-.07.624-.211s.27-.335.27-.581c0-.222-.086-.409-.258-.56-.172-.151-.384-.227-.636-.227zm.144-6.42c-.624 0-1.17.113-1.638.338-.468.225-.824.51-1.068.853-.244.343-.366.683-.366 1.017 0 .162.077.313.231.452.154.139.343.209.567.209.38 0 .638-.199.774-.597.144-.38.32-.668.528-.864.208-.196.532-.293.972-.293.376 0 .683.097.921.291.238.194.357.432.357.713 0 .144-.039.278-.117.402-.078.123-.174.235-.288.336-.114.1-.299.249-.555.446-.292.225-.524.42-.696.584-.172.164-.31.354-.414.571-.104.217-.156.473-.156.769 0 .236.071.414.213.534.142.12.317.18.525.18.4 0 .638-.183.714-.55l.099-.362.093-.206.183-.227.327-.288c.492-.387.833-.663 1.023-.827.19-.164.354-.358.492-.584.138-.225.207-.488.207-.787 0-.38-.121-.733-.363-1.057-.242-.324-.585-.58-1.029-.769-.444-.188-.956-.283-1.536-.283z" id="a"/></defs><g transform="translate(-2 -2)" fill="none"><mask><use xlink:href="#a"/></mask><use fill="#fff" xlink:href="#a"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -218,3 +218,9 @@
|
||||
.mystuff > a {
|
||||
background-image: url("/images/mystuff.png");
|
||||
}
|
||||
|
||||
.about-icon {
|
||||
height: 1.25rem;
|
||||
margin: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import profileIcon from './icon--profile.png';
|
||||
import remixIcon from './icon--remix.svg';
|
||||
import dropdownCaret from './dropdown-caret.svg';
|
||||
import languageIcon from '../language-selector/language-icon.svg';
|
||||
import aboutIcon from './icon--about.svg';
|
||||
|
||||
import scratchLogo from './scratch-logo.svg';
|
||||
|
||||
@@ -141,6 +142,19 @@ MenuItemTooltip.propTypes = {
|
||||
isRtl: PropTypes.bool
|
||||
};
|
||||
|
||||
const AboutButton = props => (
|
||||
<Button
|
||||
className={classNames(styles.menuBarItem, styles.hoverable)}
|
||||
iconClassName={styles.aboutIcon}
|
||||
iconSrc={aboutIcon}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
AboutButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class MenuBar extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
@@ -311,6 +325,8 @@ class MenuBar extends React.Component {
|
||||
{remixMessage}
|
||||
</Button>
|
||||
);
|
||||
// Show the About button only if we have a handler for it (like in the desktop app)
|
||||
const aboutButton = this.props.onClickAbout ? <AboutButton onClick={this.props.onClickAbout} /> : null;
|
||||
return (
|
||||
<Box
|
||||
className={classNames(
|
||||
@@ -689,6 +705,8 @@ class MenuBar extends React.Component {
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aboutButton}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -722,6 +740,7 @@ MenuBar.propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
loginMenuOpen: PropTypes.bool,
|
||||
logo: PropTypes.string,
|
||||
onClickAbout: PropTypes.func,
|
||||
onClickAccount: PropTypes.func,
|
||||
onClickEdit: PropTypes.func,
|
||||
onClickFile: PropTypes.func,
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import VisibilitySensor from 'react-visibility-sensor';
|
||||
|
||||
import storage from '../../lib/storage';
|
||||
|
||||
class ScratchImage extends React.PureComponent {
|
||||
static init () {
|
||||
this._maxParallelism = 6;
|
||||
this._currentJobs = 0;
|
||||
this._pendingImages = new Set();
|
||||
}
|
||||
|
||||
static loadPendingImages () {
|
||||
if (this._currentJobs >= this._maxParallelism) {
|
||||
// already busy
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first visible image. If there aren't any, find the first non-visible image.
|
||||
let nextImage;
|
||||
for (const image of this._pendingImages) {
|
||||
if (image.isVisible) {
|
||||
nextImage = image;
|
||||
break;
|
||||
} else {
|
||||
nextImage = nextImage || image;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an image to load:
|
||||
// 1) Remove it from the queue
|
||||
// 2) Load the image
|
||||
// 3) Pump the queue again
|
||||
if (nextImage) {
|
||||
this._pendingImages.delete(nextImage);
|
||||
const imageSource = nextImage.props.imageSource;
|
||||
++this._currentJobs;
|
||||
storage
|
||||
.load(imageSource.assetType, imageSource.assetId)
|
||||
.then(asset => {
|
||||
if (!nextImage.wasUnmounted) {
|
||||
const dataURI = asset.encodeDataURI();
|
||||
|
||||
nextImage.setState({
|
||||
imageURI: dataURI
|
||||
});
|
||||
}
|
||||
--this._currentJobs;
|
||||
this.loadPendingImages();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
Object.assign(this.state, this._loadImageSource(props.imageSource));
|
||||
}
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const newState = this._loadImageSource(nextProps.imageSource);
|
||||
this.setState(newState);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.wasUnmounted = true;
|
||||
ScratchImage._pendingImages.delete(this);
|
||||
}
|
||||
/**
|
||||
* Calculate the state changes necessary to load the image specified in the provided source info. If the component
|
||||
* is mounted, call setState() with the return value of this function. If the component has not yet mounted, use
|
||||
* the return value of this function as initial state for the component.
|
||||
*
|
||||
* @param {object} imageSource - the new source for the image, including either assetId or URI
|
||||
* @returns {object} - the new state values, if any.
|
||||
*/
|
||||
_loadImageSource (imageSource) {
|
||||
if (imageSource) {
|
||||
if (imageSource.uri) {
|
||||
ScratchImage._pendingImages.delete(this);
|
||||
return {
|
||||
imageURI: imageSource.uri,
|
||||
lastRequestedAsset: null
|
||||
};
|
||||
}
|
||||
if (this.state.lastRequestedAsset !== imageSource.assetId) {
|
||||
ScratchImage._pendingImages.add(this);
|
||||
return {
|
||||
lastRequestedAsset: imageSource.assetId
|
||||
};
|
||||
}
|
||||
}
|
||||
// Nothing to do - don't change any state.
|
||||
return {};
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
src: _src,
|
||||
imageSource: _imageSource,
|
||||
...imgProps
|
||||
} = this.props;
|
||||
return (
|
||||
<VisibilitySensor
|
||||
intervalCheck
|
||||
scrollCheck
|
||||
>
|
||||
{
|
||||
({isVisible}) => {
|
||||
this.isVisible = isVisible;
|
||||
ScratchImage.loadPendingImages();
|
||||
return (
|
||||
<img
|
||||
src={this.state.imageURI}
|
||||
{...imgProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</VisibilitySensor>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScratchImage.ImageSourcePropType = PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
assetId: PropTypes.string.isRequired,
|
||||
assetType: PropTypes.oneOf(Object.values(storage.AssetType)).isRequired
|
||||
}),
|
||||
PropTypes.shape({
|
||||
uri: PropTypes.string.isRequired
|
||||
})
|
||||
]);
|
||||
|
||||
ScratchImage.propTypes = {
|
||||
imageSource: ScratchImage.ImageSourcePropType.isRequired
|
||||
};
|
||||
|
||||
ScratchImage.init();
|
||||
|
||||
export default ScratchImage;
|
||||
@@ -66,7 +66,6 @@
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ class TelemetryModal extends React.PureComponent {
|
||||
privacyPolicyLink: (<a
|
||||
className={styles.privacyPolicyLink}
|
||||
href="https://scratch.mit.edu/privacy_policy/"
|
||||
onClick={this.props.onShowPrivacyPolicy}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FormattedMessage {...messages.privacyPolicyLink} />
|
||||
</a>)
|
||||
@@ -134,7 +137,8 @@ TelemetryModal.propTypes = {
|
||||
onCancel: PropTypes.func,
|
||||
onOptIn: PropTypes.func.isRequired,
|
||||
onOptOut: PropTypes.func.isRequired,
|
||||
onRequestClose: PropTypes.func
|
||||
onRequestClose: PropTypes.func,
|
||||
onShowPrivacyPolicy: PropTypes.func
|
||||
};
|
||||
|
||||
export default injectIntl(TelemetryModal);
|
||||
@@ -91,17 +91,23 @@ class LibraryItem extends React.PureComponent {
|
||||
const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length;
|
||||
this.setState({iconIndex: nextIconIndex});
|
||||
}
|
||||
curIconSource () {
|
||||
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]) {
|
||||
return this.props.icons[this.state.iconIndex];
|
||||
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.iconSource;
|
||||
return iconMd5Prop;
|
||||
}
|
||||
render () {
|
||||
const iconSource = this.curIconSource();
|
||||
const iconMd5 = this.curIconMd5();
|
||||
const iconURL = iconMd5 ?
|
||||
`https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
|
||||
this.props.iconRawURL;
|
||||
return (
|
||||
<LibraryItemComponent
|
||||
bluetoothRequired={this.props.bluetoothRequired}
|
||||
@@ -111,7 +117,8 @@ class LibraryItem extends React.PureComponent {
|
||||
extensionId={this.props.extensionId}
|
||||
featured={this.props.featured}
|
||||
hidden={this.props.hidden}
|
||||
iconSource={iconSource}
|
||||
iconURL={iconURL}
|
||||
icons={this.props.icons}
|
||||
id={this.props.id}
|
||||
insetIconURL={this.props.insetIconURL}
|
||||
internetConnectionRequired={this.props.internetConnectionRequired}
|
||||
@@ -142,8 +149,14 @@ LibraryItem.propTypes = {
|
||||
extensionId: PropTypes.string,
|
||||
featured: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
iconSource: LibraryItemComponent.propTypes.iconSource, // single icon
|
||||
icons: PropTypes.arrayOf(LibraryItemComponent.propTypes.iconSource), // rotating icons
|
||||
iconMd5: PropTypes.string,
|
||||
iconRawURL: PropTypes.string,
|
||||
icons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
baseLayerMD5: PropTypes.string, // 2.0 library format, TODO GH-5084
|
||||
md5ext: PropTypes.string // 3.0 library format
|
||||
})
|
||||
),
|
||||
id: PropTypes.number.isRequired,
|
||||
insetIconURL: PropTypes.string,
|
||||
internetConnectionRequired: PropTypes.bool,
|
||||
|
||||
+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 () {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import GUI from './containers/gui.jsx';
|
||||
import AppStateHOC from './lib/app-state-hoc.jsx';
|
||||
import TitledHOC from './lib/titled-hoc.jsx';
|
||||
import GuiReducer, {guiInitialState, guiMiddleware, initEmbedded, initFullScreen, initPlayer} from './reducers/gui';
|
||||
import LocalesReducer, {localesInitialState, initLocale} from './reducers/locales';
|
||||
import {ScratchPaintReducer} from 'scratch-paint';
|
||||
@@ -17,7 +16,6 @@ const guiReducers = {
|
||||
export {
|
||||
GUI as default,
|
||||
AppStateHOC,
|
||||
TitledHOC,
|
||||
setAppElement,
|
||||
guiReducers,
|
||||
guiInitialState,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,11 +8,23 @@ export default (filename, blob) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
downloadLink.href = url;
|
||||
downloadLink.download = filename;
|
||||
downloadLink.type = blob.type;
|
||||
downloadLink.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(downloadLink);
|
||||
if ('download' in HTMLAnchorElement.prototype) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
downloadLink.href = url;
|
||||
downloadLink.download = filename;
|
||||
downloadLink.type = blob.type;
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// iOS Safari, open a new page and set href to data-uri
|
||||
let popup = window.open('', '_blank');
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
popup.location.href = reader.result;
|
||||
popup = null;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -78,7 +78,7 @@ const DropAreaHOC = function (dragTypes) {
|
||||
}
|
||||
}
|
||||
render () {
|
||||
const componentProps = omit(this.props, ['onDrop', 'dragInfo']);
|
||||
const componentProps = omit(this.props, ['onDrop', 'dragInfo', 'componentRef']);
|
||||
return (
|
||||
<WrappedComponent
|
||||
containerRef={this.setRef}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Blue Sky 2 ",
|
||||
"md5": "5a906c2f272b77f59f0ef207857a8b3a.svg",
|
||||
"md5": "8eb8790be5507fdccf73e7c1570bbbab.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"outdoors",
|
||||
@@ -286,7 +286,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Circles",
|
||||
"md5": "4e29033ec2b891a8f1ca21242811d403.svg",
|
||||
"md5": "c9847be305920807c5597d81576dd0c4.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"patterns"
|
||||
@@ -491,7 +491,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Hearts",
|
||||
"md5": "26d3418b2fbc1af2c5fea82e1c3df1db.svg",
|
||||
"md5": "f98526ccb0eec3ac7d6c8f8ab502825e.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"patterns"
|
||||
@@ -1050,7 +1050,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Underwater 1",
|
||||
"md5": "fb907f72b310acc8b95cbf2d2cccabc9.svg",
|
||||
"md5": "d3344650f594bcecdf46aa4a9441badd.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"ocean",
|
||||
@@ -1175,7 +1175,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Witch House",
|
||||
"md5": "597b9a9813fe5d8d387283138a0b8f2b.svg",
|
||||
"md5": "30085b2d27beb5acdbe895d8b3e64b04.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"fantasy",
|
||||
@@ -1190,7 +1190,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Woods",
|
||||
"md5": "d26cfd278999c9e37d6af75c296a58df.svg",
|
||||
"md5": "f3eb165d6f3fd23370f97079f2e631bf.svg",
|
||||
"type": "backdrop",
|
||||
"tags": [
|
||||
"fantasy",
|
||||
|
||||
+1740
-801
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,8 @@ const loadSpanish = () =>
|
||||
.then(({esImages: imageData}) => imageData);
|
||||
|
||||
const translations = {
|
||||
es: () => loadSpanish()
|
||||
'es': () => loadSpanish(),
|
||||
'es-419': () => loadSpanish()
|
||||
};
|
||||
|
||||
const loadImageData = locale => {
|
||||
|
||||
+545
-553
File diff suppressed because it is too large.
Load diff
+14873
-10065
File diff suppressed because it is too large.
Load diff
@@ -251,6 +251,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
||||
if (id && this.props.onUpdateProjectThumbnail) {
|
||||
this.storeProjectThumbnail(id);
|
||||
}
|
||||
this.reportTelemetryEvent('projectDidSave');
|
||||
return response;
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -291,9 +292,15 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
||||
*/
|
||||
// TODO make a telemetry HOC and move this stuff there
|
||||
reportTelemetryEvent (event) {
|
||||
if (this.props.onProjectTelemetryEvent) {
|
||||
const metadata = collectMetadata(this.props.vm, this.props.reduxProjectTitle, this.props.locale);
|
||||
this.props.onProjectTelemetryEvent(event, metadata);
|
||||
try {
|
||||
if (this.props.onProjectTelemetryEvent) {
|
||||
const metadata = collectMetadata(this.props.vm, this.props.reduxProjectTitle, this.props.locale);
|
||||
this.props.onProjectTelemetryEvent(event, metadata);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Telemetry error', event, e);
|
||||
// This is intentionally fire/forget because a failure
|
||||
// to report telemetry should not block saving
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +440,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.
|
||||
|
||||
@@ -4,8 +4,8 @@ const randomizeSpritePosition = spriteObject => {
|
||||
const randomY = Math.floor((100 * Math.random()) - 50);
|
||||
if (spriteObject.hasOwnProperty('json')) {
|
||||
// Library sprite object
|
||||
spriteObject.json.scratchX = randomX;
|
||||
spriteObject.json.scratchY = randomY;
|
||||
spriteObject.json.x = randomX;
|
||||
spriteObject.json.y = randomY;
|
||||
} else if (spriteObject.hasOwnProperty('x') && spriteObject.hasOwnProperty('y')) {
|
||||
// Scratch 3 sprite object
|
||||
spriteObject.x = randomX;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const START_AUTO_UPDATING = 'scratch-gui/project-state/START_AUTO_UPDATING';
|
||||
const START_CREATING_NEW = 'scratch-gui/project-state/START_CREATING_NEW';
|
||||
const START_ERROR = 'scratch-gui/project-state/START_ERROR';
|
||||
const START_FETCHING_NEW = 'scratch-gui/project-state/START_FETCHING_NEW';
|
||||
const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_FILE_UPLOAD';
|
||||
const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_VM_FILE_UPLOAD';
|
||||
const START_MANUAL_UPDATING = 'scratch-gui/project-state/START_MANUAL_UPDATING';
|
||||
const START_REMIXING = 'scratch-gui/project-state/START_REMIXING';
|
||||
const START_UPDATING_BEFORE_CREATING_COPY = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_COPY';
|
||||
@@ -413,32 +413,31 @@ const onFetchedProjectData = (projectData, loadingState) => {
|
||||
};
|
||||
|
||||
const onLoadedProject = (loadingState, canSave, success) => {
|
||||
if (success) {
|
||||
switch (loadingState) {
|
||||
case LoadingState.LOADING_VM_WITH_ID:
|
||||
return {
|
||||
type: DONE_LOADING_VM_WITH_ID
|
||||
};
|
||||
case LoadingState.LOADING_VM_FILE_UPLOAD:
|
||||
if (canSave) {
|
||||
return {
|
||||
type: DONE_LOADING_VM_TO_SAVE
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: DONE_LOADING_VM_WITHOUT_ID
|
||||
};
|
||||
case LoadingState.LOADING_VM_NEW_DEFAULT:
|
||||
return {
|
||||
type: DONE_LOADING_VM_WITHOUT_ID
|
||||
};
|
||||
default:
|
||||
return;
|
||||
switch (loadingState) {
|
||||
case LoadingState.LOADING_VM_WITH_ID:
|
||||
if (success) {
|
||||
return {type: DONE_LOADING_VM_WITH_ID};
|
||||
}
|
||||
// failed to load project; just keep showing current project
|
||||
return {type: RETURN_TO_SHOWING};
|
||||
case LoadingState.LOADING_VM_FILE_UPLOAD:
|
||||
if (success) {
|
||||
if (canSave) {
|
||||
return {type: DONE_LOADING_VM_TO_SAVE};
|
||||
}
|
||||
return {type: DONE_LOADING_VM_WITHOUT_ID};
|
||||
}
|
||||
// failed to load project; just keep showing current project
|
||||
return {type: RETURN_TO_SHOWING};
|
||||
case LoadingState.LOADING_VM_NEW_DEFAULT:
|
||||
if (success) {
|
||||
return {type: DONE_LOADING_VM_WITHOUT_ID};
|
||||
}
|
||||
// failed to load default project; show error
|
||||
return {type: START_ERROR};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: RETURN_TO_SHOWING
|
||||
};
|
||||
};
|
||||
|
||||
const doneUpdatingProject = loadingState => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
en: {}
|
||||
};
|
||||
@@ -19,6 +19,7 @@ class SeleniumHelper {
|
||||
'clickText',
|
||||
'clickButton',
|
||||
'clickXpath',
|
||||
'clickBlocksCategory',
|
||||
'elementIsVisible',
|
||||
'findByText',
|
||||
'findByXpath',
|
||||
@@ -28,6 +29,8 @@ class SeleniumHelper {
|
||||
'loadUri',
|
||||
'rightClickText'
|
||||
]);
|
||||
|
||||
this.Key = webdriver.Key; // map Key constants, for sending special keys
|
||||
}
|
||||
|
||||
elementIsVisible (element, timeoutMessage = 'elementIsVisible timed out') {
|
||||
@@ -122,6 +125,17 @@ class SeleniumHelper {
|
||||
return this.findByText(text, scope).then(el => el.click());
|
||||
}
|
||||
|
||||
async clickBlocksCategory (categoryText) {
|
||||
// The toolbox is destroyed and recreated several times, so avoid clicking on a nonexistent element and erroring
|
||||
// out. First we wait for the block pane itself to appear, then wait 100ms for the toolbox to finish refreshing,
|
||||
// then finally click the toolbox text.
|
||||
|
||||
await this.findByXpath('//div[contains(@class, "blocks_blocks")]');
|
||||
await this.driver.sleep(100);
|
||||
await this.clickText(categoryText, 'div[contains(@class, "blocks_blocks")]');
|
||||
await this.driver.sleep(500); // Wait for scroll to finish
|
||||
}
|
||||
|
||||
rightClickText (text, scope) {
|
||||
return this.findByText(text, scope).then(el => this.driver.actions()
|
||||
.click(el, Button.RIGHT)
|
||||
|
||||
@@ -3,12 +3,14 @@ import SeleniumHelper from '../helpers/selenium-helper';
|
||||
|
||||
const {
|
||||
clickText,
|
||||
clickBlocksCategory,
|
||||
clickButton,
|
||||
clickXpath,
|
||||
findByText,
|
||||
findByXpath,
|
||||
getDriver,
|
||||
getLogs,
|
||||
Key,
|
||||
loadUri,
|
||||
rightClickText,
|
||||
scope
|
||||
@@ -30,8 +32,7 @@ describe('Working with the blocks', () => {
|
||||
test('Blocks report when clicked in the toolbox', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Code');
|
||||
await clickText('Operators', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Operators');
|
||||
await clickText('join', scope.blocksTab); // Click "join <hello> <world>" block
|
||||
await findByText('apple banana', scope.reportedValue); // Tooltip with result
|
||||
const logs = await getLogs();
|
||||
@@ -40,14 +41,13 @@ describe('Working with the blocks', () => {
|
||||
|
||||
test('Switching sprites updates the block menus', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Sound', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Sound');
|
||||
// "Meow" sound block should be visible
|
||||
await findByText('Meow', scope.blocksTab);
|
||||
await clickText('Backdrops'); // Switch to the backdrop
|
||||
// Now "pop" sound block should be visible and motion blocks hidden
|
||||
await findByText('pop', scope.blocksTab);
|
||||
await clickText('Motion', scope.blocksTab);
|
||||
await clickBlocksCategory('Motion');
|
||||
await findByText('Stage selected: no motion blocks');
|
||||
|
||||
const logs = await getLogs();
|
||||
@@ -57,8 +57,7 @@ describe('Working with the blocks', () => {
|
||||
test('Creating variables', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Code');
|
||||
await clickText('Variables', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Variables');
|
||||
|
||||
// Expect a default variable "my variable" to be visible
|
||||
await clickText('my\u00A0variable', scope.blocksTab);
|
||||
@@ -74,8 +73,7 @@ describe('Working with the blocks', () => {
|
||||
await clickButton('OK');
|
||||
|
||||
// Make sure reporting works on a new variable
|
||||
await clickText('Variables', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Variables');
|
||||
await clickText('score', scope.blocksTab);
|
||||
await findByText('0', scope.reportedValue); // Tooltip with result
|
||||
|
||||
@@ -99,8 +97,7 @@ describe('Working with the blocks', () => {
|
||||
test('Creating a list', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Code');
|
||||
await clickText('Variables', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Variables');
|
||||
|
||||
await clickText('Make a List');
|
||||
let el = await findByXpath("//input[@name='New list name:']");
|
||||
@@ -132,8 +129,7 @@ describe('Working with the blocks', () => {
|
||||
|
||||
test('Custom procedures', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('My Blocks');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('My Blocks');
|
||||
await clickText('Make a Block');
|
||||
// Click on the "add an input" buttons
|
||||
await clickText('number or text', scope.modal);
|
||||
@@ -164,8 +160,7 @@ describe('Working with the blocks', () => {
|
||||
test('Record option from sound block menu opens sound recorder', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Code');
|
||||
await clickText('Sound', scope.blocksTab);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
|
||||
await clickBlocksCategory('Sound');
|
||||
await clickText('Meow', scope.blocksTab); // Click "play sound <Meow> until done" block
|
||||
await clickText('record'); // Click "record..." option in the block's sound menu
|
||||
// Access has been force denied, so close the alert that comes up
|
||||
@@ -185,11 +180,14 @@ describe('Working with the blocks', () => {
|
||||
await clickText('costume2', scope.costumesTab);
|
||||
const el = await findByXpath("//input[@value='costume2']");
|
||||
await el.sendKeys('newname');
|
||||
await el.sendKeys(Key.ENTER);
|
||||
// wait until the updated costume appears in costume item list panel
|
||||
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
|
||||
"//div[contains(text(), 'newname')]");
|
||||
|
||||
// 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 clickBlocksCategory('Looks');
|
||||
await clickText('newname', scope.blocksTab);
|
||||
});
|
||||
|
||||
@@ -201,22 +199,24 @@ describe('Working with the blocks', () => {
|
||||
await clickText('costume2', scope.costumesTab);
|
||||
const el = await findByXpath("//input[@value='costume2']");
|
||||
await el.sendKeys('<NewCostume>');
|
||||
await el.sendKeys(Key.ENTER);
|
||||
// wait until the updated costume appears in costume item list panel
|
||||
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
|
||||
"//div[contains(text(), '<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 clickBlocksCategory('Looks');
|
||||
await clickText('<NewCostume>', scope.blocksTab);
|
||||
|
||||
await clickText('Sound', scope.blocksTab);
|
||||
await clickBlocksCategory('Sound');
|
||||
});
|
||||
|
||||
test('Adding costumes DOES update the default costume name in the toolbox', async () => {
|
||||
await loadUri(uri);
|
||||
|
||||
// By default, costume2 is in the costume tab
|
||||
await clickText('Looks', scope.blocksTab);
|
||||
await driver.sleep(500); // Wait for scroll to finish
|
||||
await clickBlocksCategory('Looks');
|
||||
await clickText('costume2', scope.blocksTab);
|
||||
|
||||
// Also check that adding a new costume does update the list
|
||||
@@ -226,6 +226,9 @@ describe('Working with the blocks', () => {
|
||||
.perform();
|
||||
await driver.sleep(500); // Wait for thermometer menu to come up
|
||||
await clickXpath('//button[@aria-label="Paint"]');
|
||||
// wait until the new costume appears in costume item list panel
|
||||
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
|
||||
"//div[contains(text(), 'costume3')]");
|
||||
await clickText('costume3', scope.costumesTab);
|
||||
// Check that the menu has been updated
|
||||
await clickText('Code');
|
||||
@@ -233,14 +236,16 @@ describe('Working with the blocks', () => {
|
||||
});
|
||||
|
||||
// Skipped because it was flakey on travis, but seems to run locally ok
|
||||
test.skip('Adding a sound DOES update the default sound name in the toolbox', async () => {
|
||||
test('Adding a sound DOES update the default sound name in the toolbox', async () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Sounds');
|
||||
await clickXpath('//button[@aria-label="Choose a Sound"]');
|
||||
await clickText('A Bass', scope.modal); // Should close the modal
|
||||
// wait until the selected sound appears in sounds item list panel
|
||||
await findByXpath("//div[contains(@class,'sprite-selector-item_is-selected_')]" +
|
||||
"//div[contains(text(), 'A Bass')]");
|
||||
await clickText('Code');
|
||||
await clickText('Sound', scope.blocksTab);
|
||||
await driver.sleep(500); // Wait for scroll to finish
|
||||
await clickBlocksCategory('Sound');
|
||||
await clickText('A\u00A0Bass', scope.blocksTab); // Need for block text
|
||||
});
|
||||
|
||||
@@ -249,15 +254,13 @@ describe('Working with the blocks', () => {
|
||||
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 clickBlocksCategory('Variables');
|
||||
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 clickBlocksCategory('Variables');
|
||||
await clickText('my\u00A0variable');
|
||||
});
|
||||
|
||||
@@ -266,8 +269,7 @@ describe('Working with the blocks', () => {
|
||||
await loadUri(uri);
|
||||
await clickText('Costumes');
|
||||
await clickText('Code');
|
||||
await clickText('Variables', scope.blocksTab);
|
||||
await driver.sleep(500); // Wait for scroll
|
||||
await clickBlocksCategory('Variables');
|
||||
await clickText('Make a List');
|
||||
const el = await findByXpath("//input[@name='New list name:']");
|
||||
await el.sendKeys('list1');
|
||||
|
||||
@@ -193,11 +193,7 @@ describe('Working with costumes', () => {
|
||||
.mouseMove(abbyElement)
|
||||
.perform();
|
||||
// wait for one of Abby's alternate costumes to appear
|
||||
const src1 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
|
||||
await driver.sleep(300);
|
||||
const src2 = await abbyElement.findElement({css: 'img'}).getAttribute('src');
|
||||
const sourcesMatch = (src1 === src2);
|
||||
await expect(sourcesMatch).toBeFalsy(); // 'src' attribute should have changed by now
|
||||
await findByXpath('//img[@src="https://cdn.assets.scratch.mit.edu/internalapi/asset/45de34b47a2ce22f6f5d28bb35a44ff5.svg/get/"]');
|
||||
const logs = await getLogs();
|
||||
await expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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}"]`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import {mountWithIntl} from '../../helpers/intl-helpers';
|
||||
import MenuBar from '../../../src/components/menu-bar/menu-bar';
|
||||
import {menuInitialState} from '../../../src/reducers/menus';
|
||||
import {LoadingState} from '../../../src/reducers/project-state';
|
||||
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {Provider} from 'react-redux';
|
||||
import VM from 'scratch-vm';
|
||||
|
||||
|
||||
describe('MenuBar Component', () => {
|
||||
const store = configureStore()({
|
||||
locales: {
|
||||
isRtl: false,
|
||||
locale: 'en-US'
|
||||
},
|
||||
scratchGui: {
|
||||
menus: menuInitialState,
|
||||
projectState: {
|
||||
loadingState: LoadingState.NOT_LOADED
|
||||
},
|
||||
vm: new VM()
|
||||
}
|
||||
});
|
||||
|
||||
const getComponent = function (props = {}) {
|
||||
return <Provider store={store}><MenuBar {...props} /></Provider>;
|
||||
};
|
||||
|
||||
test('menu bar with no About handler has no About button', () => {
|
||||
const menuBar = mountWithIntl(getComponent());
|
||||
const button = menuBar.find('AboutButton');
|
||||
expect(button.exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('menu bar with an About handler has an About button', () => {
|
||||
const onClickAbout = jest.fn();
|
||||
const menuBar = mountWithIntl(getComponent({onClickAbout}));
|
||||
const button = menuBar.find('AboutButton');
|
||||
expect(button.exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('clicking on About button calls the handler', () => {
|
||||
const onClickAbout = jest.fn();
|
||||
const menuBar = mountWithIntl(getComponent({onClickAbout}));
|
||||
const button = menuBar.find('AboutButton');
|
||||
expect(onClickAbout).toHaveBeenCalledTimes(0);
|
||||
button.simulate('click');
|
||||
expect(onClickAbout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -92,99 +92,181 @@ test('onFetchedProjectData new loads project data into vm', () => {
|
||||
expect(resultState.projectData).toBe('1010101');
|
||||
});
|
||||
|
||||
test('onLoadedProject upload, with canSave false, shows without id', () => {
|
||||
const initialState = {
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
});
|
||||
// onLoadedProject: LOADING_VM_WITH_ID
|
||||
|
||||
test('onLoadedProject upload, with canSave true, prepares to save', () => {
|
||||
const initialState = {
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, true, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
|
||||
});
|
||||
|
||||
test('onLoadedProject with id shows with id', () => {
|
||||
test('onLoadedProject(LOADING_VM_WITH_ID, true, true) results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, true, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('onLoadedProject new shows without id', () => {
|
||||
test('onLoadedProject(LOADING_VM_WITH_ID, false, true) results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
// case where we started out viewing a project with a projectId, then
|
||||
// started to load another project; but loading fails. We go back to
|
||||
// showing the original project.
|
||||
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with project id, ' +
|
||||
'results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
// case where we started out viewing a project with default projectId, then
|
||||
// started to load one with an id, such as in standalone mode when user adds
|
||||
// '#PROJECT_ID_NUMBER' to the URI; but loading fails. We go back to
|
||||
// showing the original project.
|
||||
test('onLoadedProject(LOADING_VM_WITH_ID, false, false), with no project id, ' +
|
||||
'results in state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
// onLoadedProject: LOADING_VM_FILE_UPLOAD
|
||||
|
||||
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, true, true) prepares to save', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, true, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, true) results in state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('onLoadedProject new, to save shows without id', () => {
|
||||
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we know project id, ' +
|
||||
'results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('onLoadedProject(LOADING_VM_FILE_UPLOAD, false, false), when we ' +
|
||||
'don\'t know project id, results in state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_FILE_UPLOAD
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
// onLoadedProject: LOADING_VM_NEW_DEFAULT
|
||||
|
||||
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, true, true) results in state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, true, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('onLoadedProject with success false, no project id, shows without id', () => {
|
||||
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, true) results in state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID,
|
||||
projectId: null
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const action = onLoadedProject(initialState.loadingState, false, true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('onLoadedProject with success false, valid project id, shows with id', () => {
|
||||
test('onLoadedProject(LOADING_VM_NEW_DEFAULT, false, false) results in ERROR state', () => {
|
||||
const initialState = {
|
||||
loadingState: LoadingState.LOADING_VM_WITH_ID,
|
||||
projectId: '12345'
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.LOADING_VM_NEW_DEFAULT
|
||||
};
|
||||
const action = onLoadedProject(initialState.loadingState, false, false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.loadingState).toBe(LoadingState.ERROR);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('doneUpdatingProject with id shows with id', () => {
|
||||
// doneUpdatingProject
|
||||
|
||||
test('doneUpdatingProject with id results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.MANUAL_UPDATING
|
||||
};
|
||||
const action = doneUpdatingProject(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('doneUpdatingProject with id, before copy, creates copy', () => {
|
||||
test('doneUpdatingProject with id, before copy occurs, results in state CREATING_COPY', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.UPDATING_BEFORE_COPY
|
||||
};
|
||||
const action = doneUpdatingProject(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('doneUpdatingProject with id, before new, fetches default project', () => {
|
||||
test('doneUpdatingProject with id, before new, results in state FETCHING_NEW_DEFAULT', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.UPDATING_BEFORE_NEW
|
||||
};
|
||||
const action = doneUpdatingProject(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
|
||||
expect(resultState.projectId).toBe('0'); // resets id
|
||||
});
|
||||
|
||||
test('setProjectId, with same id as before, should show with id, not fetch', () => {
|
||||
test('calling setProjectId, using with same id as already showing, ' +
|
||||
'results in state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
@@ -195,7 +277,8 @@ test('setProjectId, with same id as before, should show with id, not fetch', ()
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('setProjectId, with different id as before, should fetch with id, not show with id', () => {
|
||||
test('calling setProjectId, using different id from project already showing, ' +
|
||||
'results in state FETCHING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
projectId: 99,
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
@@ -206,7 +289,8 @@ test('setProjectId, with different id as before, should fetch with id, not show
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('setProjectId, with same id as before, but not same type, should fetch because not ===', () => {
|
||||
test('setProjectId, with same id as before, but not same type, ' +
|
||||
'results in FETCHING_WITH_ID because the two projectIds are not strictly equal', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
@@ -217,85 +301,104 @@ test('setProjectId, with same id as before, but not same type, should fetch beca
|
||||
expect(resultState.projectId).toBe(100);
|
||||
});
|
||||
|
||||
test('requestNewProject, when can\'t create new, should fetch default project without id', () => {
|
||||
test('requestNewProject, when can\'t create/save, results in FETCHING_NEW_DEFAULT', () => {
|
||||
const initialState = {
|
||||
projectId: '0',
|
||||
loadingState: LoadingState.SHOWING_WITHOUT_ID
|
||||
};
|
||||
const action = requestNewProject(false);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('requestNewProject, when can create new, should save and prepare to fetch default project', () => {
|
||||
test('requestNewProject, when can create/save, results in UPDATING_BEFORE_NEW ' +
|
||||
'(in order to save before fetching default project)', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = requestNewProject(true);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_NEW);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('requestProjectUpload when project not loaded should load', () => {
|
||||
test('requestProjectUpload when project not loaded results in state LOADING_VM_FILE_UPLOAD', () => {
|
||||
const initialState = {
|
||||
projectId: null,
|
||||
loadingState: LoadingState.NOT_LOADED
|
||||
};
|
||||
const action = requestProjectUpload(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
|
||||
expect(resultState.projectId).toBe(null);
|
||||
});
|
||||
|
||||
test('requestProjectUpload when showing project with id should load', () => {
|
||||
test('requestProjectUpload when showing project with id results in state LOADING_VM_FILE_UPLOAD', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = requestProjectUpload(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('requestProjectUpload when showing project without id should load', () => {
|
||||
test('requestProjectUpload when showing project without id results in state LOADING_VM_FILE_UPLOAD', () => {
|
||||
const initialState = {
|
||||
projectId: null,
|
||||
loadingState: LoadingState.SHOWING_WITHOUT_ID
|
||||
};
|
||||
const action = requestProjectUpload(initialState.loadingState);
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_FILE_UPLOAD);
|
||||
expect(resultState.projectId).toBe(null);
|
||||
});
|
||||
|
||||
test('manualUpdateProject should prepare to update', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = manualUpdateProject();
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.MANUAL_UPDATING);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('autoUpdateProject should prepare to update', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = autoUpdateProject();
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('saveProjectAsCopy should save, before preparing to save as a copy', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = saveProjectAsCopy();
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('remixProject should prepare to remix', () => {
|
||||
const initialState = {
|
||||
projectId: '100',
|
||||
loadingState: LoadingState.SHOWING_WITH_ID
|
||||
};
|
||||
const action = remixProject();
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.REMIXING);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
});
|
||||
|
||||
test('projectError from various states should show error', () => {
|
||||
@@ -314,11 +417,13 @@ test('projectError from various states should show error', () => {
|
||||
for (const startState of startStates) {
|
||||
const initialState = {
|
||||
error: null,
|
||||
projectId: '100',
|
||||
loadingState: startState
|
||||
};
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.error).toEqual('Error string');
|
||||
expect(resultState.projectId).toBe('100');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -332,11 +437,13 @@ test('fatal projectError should show error state', () => {
|
||||
for (const startState of startStates) {
|
||||
const initialState = {
|
||||
error: null,
|
||||
projectId: '100',
|
||||
loadingState: startState
|
||||
};
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.ERROR);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -351,15 +458,18 @@ test('non-fatal projectError should show normal state', () => {
|
||||
for (const startState of startStates) {
|
||||
const initialState = {
|
||||
error: null,
|
||||
projectId: '100',
|
||||
loadingState: startState
|
||||
};
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('100');
|
||||
}
|
||||
});
|
||||
|
||||
test('projectError when creating new while viewing project with id should show project with id', () => {
|
||||
test('projectError when creating new while viewing project with id should ' +
|
||||
'go back to state SHOWING_WITH_ID', () => {
|
||||
const initialState = {
|
||||
error: null,
|
||||
loadingState: LoadingState.CREATING_NEW,
|
||||
@@ -368,9 +478,11 @@ test('projectError when creating new while viewing project with id should show p
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID);
|
||||
expect(resultState.projectId).toBe('12345');
|
||||
});
|
||||
|
||||
test('projectError when creating new while logged out, looking at default project should show default project', () => {
|
||||
test('projectError when creating new while logged out, looking at default project ' +
|
||||
'should go back to state SHOWING_WITHOUT_ID', () => {
|
||||
const initialState = {
|
||||
error: null,
|
||||
loadingState: LoadingState.CREATING_NEW,
|
||||
@@ -379,15 +491,19 @@ test('projectError when creating new while logged out, looking at default projec
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID);
|
||||
expect(resultState.projectId).toBe('0');
|
||||
});
|
||||
|
||||
test('projectError from showing project should show error', () => {
|
||||
test('projectError encountered while in state FETCHING_WITH_ID results in ' +
|
||||
'ERROR state', () => {
|
||||
const initialState = {
|
||||
error: null,
|
||||
projectId: null,
|
||||
loadingState: LoadingState.FETCHING_WITH_ID
|
||||
};
|
||||
const action = projectError('Error string');
|
||||
const resultState = projectStateReducer(initialState, action);
|
||||
expect(resultState.loadingState).toBe(LoadingState.ERROR);
|
||||
expect(resultState.projectId).toBe(null);
|
||||
expect(resultState.error).toEqual('Error string');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('projectSaverHOC', () => {
|
||||
expect(mockedUpdateProject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if canSave is alreatdy true and we show a project with an id, project will NOT be saved', () => {
|
||||
test('if canSave is already true and we show a project with an id, project will NOT be saved', () => {
|
||||
const mockedSaveProject = jest.fn();
|
||||
const Component = () => <div />;
|
||||
const WrappedComponent = projectSaverHOC(Component);
|
||||
|
||||
Reference in new issue
Block a user