├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── deploy.yml │ ├── signature-assistant.yml │ └── update-l10n.yml ├── .gitignore ├── .husky ├── .gitattributes └── commit-msg ├── .npmignore ├── .nvmrc ├── .tx └── config ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TRADEMARK ├── commitlint.config.js ├── package-lock.json ├── package.json ├── release.config.js ├── renovate.json5 ├── scripts └── build-i18n-source.js ├── src ├── .eslintrc.js ├── components │ ├── bit-brush-mode │ │ ├── bit-brush-mode.jsx │ │ └── brush.svg │ ├── bit-eraser-mode │ │ ├── bit-eraser-mode.jsx │ │ └── eraser.svg │ ├── bit-fill-mode │ │ ├── bit-fill-mode.jsx │ │ └── fill.svg │ ├── bit-line-mode │ │ ├── bit-line-mode.jsx │ │ └── line.svg │ ├── bit-oval-mode │ │ ├── bit-oval-mode.jsx │ │ ├── oval-outlined.svg │ │ └── oval.svg │ ├── bit-rect-mode │ │ ├── bit-rect-mode.jsx │ │ ├── rectangle-outlined.svg │ │ └── rectangle.svg │ ├── bit-select-mode │ │ ├── bit-select-mode.jsx │ │ └── marquee.svg │ ├── bit-text-mode │ │ ├── bit-text-mode.jsx │ │ └── text.svg │ ├── box │ │ └── box.jsx │ ├── brush-mode │ │ ├── brush-mode.jsx │ │ └── brush.svg │ ├── button-group │ │ ├── button-group.css │ │ └── button-group.jsx │ ├── button │ │ ├── button.css │ │ └── button.jsx │ ├── color-button │ │ ├── color-button.css │ │ ├── color-button.jsx │ │ ├── mixed-fill.svg │ │ └── no-fill.svg │ ├── color-indicator.jsx │ ├── color-picker │ │ ├── color-picker.css │ │ ├── color-picker.jsx │ │ └── icons │ │ │ ├── eye-dropper.svg │ │ │ ├── fill-horz-gradient-enabled.svg │ │ │ ├── fill-radial-enabled.svg │ │ │ ├── fill-solid-enabled.svg │ │ │ ├── fill-vert-gradient-enabled.svg │ │ │ └── swap.svg │ ├── coming-soon │ │ ├── aww-cat.png │ │ ├── coming-soon.css │ │ ├── coming-soon.jsx │ │ └── cool-cat.png │ ├── dropdown │ │ ├── dropdown-caret.svg │ │ ├── dropdown.css │ │ └── dropdown.jsx │ ├── eraser-mode │ │ ├── eraser-mode.jsx │ │ └── eraser.svg │ ├── fill-mode │ │ ├── fill-mode.jsx │ │ └── fill.svg │ ├── fixed-tools │ │ ├── fixed-tools.css │ │ ├── fixed-tools.jsx │ │ └── icons │ │ │ ├── group.svg │ │ │ ├── redo.svg │ │ │ ├── send-back.svg │ │ │ ├── send-backward.svg │ │ │ ├── send-forward.svg │ │ │ ├── send-front.svg │ │ │ ├── undo.svg │ │ │ └── ungroup.svg │ ├── font-dropdown │ │ ├── font-dropdown.css │ │ └── font-dropdown.jsx │ ├── forms │ │ ├── buffered-input-hoc.jsx │ │ ├── input.css │ │ ├── input.jsx │ │ ├── label.css │ │ ├── label.jsx │ │ ├── live-input-hoc.jsx │ │ ├── slider.css │ │ └── slider.jsx │ ├── input-group │ │ ├── input-group.css │ │ └── input-group.jsx │ ├── labeled-icon-button │ │ ├── labeled-icon-button.css │ │ └── labeled-icon-button.jsx │ ├── line-mode │ │ ├── line-mode.jsx │ │ └── line.svg │ ├── loupe │ │ ├── loupe.css │ │ └── loupe.jsx │ ├── mode-tools │ │ ├── icons │ │ │ ├── copy.svg │ │ │ ├── curved-point.svg │ │ │ ├── delete.svg │ │ │ ├── flip-horizontal.svg │ │ │ ├── flip-vertical.svg │ │ │ ├── paste.svg │ │ │ └── straight-point.svg │ │ ├── mode-tools.css │ │ └── mode-tools.jsx │ ├── oval-mode │ │ ├── oval-mode.jsx │ │ └── oval.svg │ ├── paint-editor │ │ ├── icons │ │ │ ├── bitmap.svg │ │ │ ├── rotation-point.svg │ │ │ ├── zoom-in.svg │ │ │ ├── zoom-out.svg │ │ │ └── zoom-reset.svg │ │ ├── paint-editor.css │ │ └── paint-editor.jsx │ ├── rect-mode │ │ ├── rect-mode.jsx │ │ └── rectangle.svg │ ├── reshape-mode │ │ ├── reshape-mode.jsx │ │ └── reshape.svg │ ├── rounded-rect-mode │ │ ├── rounded-rect-mode.jsx │ │ └── rounded-rectangle.svg │ ├── scrollable-canvas │ │ ├── scrollable-canvas.css │ │ └── scrollable-canvas.jsx │ ├── select-mode │ │ ├── select-mode.jsx │ │ └── select.svg │ ├── stroke-width-indicator.jsx │ ├── text-mode │ │ ├── text-mode.jsx │ │ └── text.svg │ └── tool-select-base │ │ ├── tool-select-base.css │ │ └── tool-select-base.jsx ├── containers │ ├── bit-brush-mode.jsx │ ├── bit-eraser-mode.jsx │ ├── bit-fill-mode.jsx │ ├── bit-line-mode.jsx │ ├── bit-oval-mode.jsx │ ├── bit-rect-mode.jsx │ ├── bit-select-mode.jsx │ ├── brush-mode.jsx │ ├── color-indicator.jsx │ ├── color-picker.jsx │ ├── eraser-mode.jsx │ ├── fill-color-indicator.jsx │ ├── fill-mode.jsx │ ├── fixed-tools.jsx │ ├── font-dropdown.jsx │ ├── line-mode.jsx │ ├── mode-tools.jsx │ ├── oval-mode.jsx │ ├── paint-editor.jsx │ ├── paper-canvas.css │ ├── paper-canvas.jsx │ ├── rect-mode.jsx │ ├── reshape-mode.jsx │ ├── rounded-rect-mode.jsx │ ├── scrollable-canvas.jsx │ ├── select-mode.jsx │ ├── stroke-color-indicator.jsx │ ├── stroke-width-indicator.jsx │ └── text-mode.jsx ├── css │ ├── colors.css │ └── units.css ├── helper │ ├── bit-tools │ │ ├── brush-tool.js │ │ ├── fill-tool.js │ │ ├── line-tool.js │ │ ├── oval-tool.js │ │ ├── rect-tool.js │ │ └── select-tool.js │ ├── bitmap.js │ ├── blob-tools │ │ ├── blob.js │ │ ├── broad-brush-helper.js │ │ └── segment-brush-helper.js │ ├── compound-path.js │ ├── group.js │ ├── guides.js │ ├── hover.js │ ├── item.js │ ├── layer.js │ ├── math.js │ ├── order.js │ ├── selection-tools │ │ ├── bounding-box-tool.js │ │ ├── handle-tool.js │ │ ├── move-tool.js │ │ ├── nudge-tool.js │ │ ├── point-tool.js │ │ ├── reshape-tool.js │ │ ├── rotate-tool.js │ │ ├── scale-tool.js │ │ ├── select-tool.js │ │ └── selection-box-tool.js │ ├── selection.js │ ├── snapping.js │ ├── style-path.js │ ├── tools │ │ ├── eye-dropper.js │ │ ├── fill-tool.js │ │ ├── oval-tool.js │ │ ├── rect-tool.js │ │ ├── rounded-rect-tool.js │ │ └── text-tool.js │ ├── undo.js │ └── view.js ├── hocs │ ├── copy-paste-hoc.jsx │ ├── keyboard-shortcuts-hoc.jsx │ ├── selection-hoc.jsx │ ├── undo-hoc.jsx │ └── update-image-hoc.jsx ├── index.js ├── lib │ ├── color-style-proptype.js │ ├── cursors.js │ ├── fonts.js │ ├── format.js │ ├── gradient-types.js │ ├── hide-label.js │ ├── layout-constants.js │ ├── make-color-style-reducer.js │ ├── messages.js │ ├── modes.js │ └── touch-utils.js ├── log │ └── log.js ├── playground │ ├── index.ejs │ ├── playground.css │ ├── playground.jsx │ └── reducers │ │ ├── combine-reducers.js │ │ └── intl.js └── reducers │ ├── bit-brush-size.js │ ├── bit-eraser-size.js │ ├── brush-mode.js │ ├── clipboard.js │ ├── color-index.js │ ├── color.js │ ├── cursor.js │ ├── eraser-mode.js │ ├── eye-dropper.js │ ├── fill-bitmap-shapes.js │ ├── fill-mode-gradient-type.js │ ├── fill-mode.js │ ├── fill-style.js │ ├── font.js │ ├── format.js │ ├── hover.js │ ├── layout.js │ ├── modals.js │ ├── modes.js │ ├── scratch-paint-reducer.js │ ├── selected-items.js │ ├── stroke-style.js │ ├── stroke-width.js │ ├── text-edit-target.js │ ├── undo.js │ ├── view-bounds.js │ └── zoom-levels.js ├── test ├── __mocks__ │ ├── fileMock.js │ ├── paperMocks.js │ ├── react-intl.js │ └── styleMock.js ├── helpers │ └── enzyme-setup.js └── unit │ ├── blob-mode-reducer.test.js │ ├── clipboard-reducer.test.js │ ├── color-reducer.test.js │ ├── components │ └── button-click.test.jsx │ ├── format-reducer.test.js │ ├── hover-reducer.test.js │ ├── modes-reducer.test.js │ ├── selected-items-reducer.test.js │ ├── stroke-width-reducer.test.js │ └── undo-reducer.test.js ├── tmp.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-object-rest-spread", 4 | ["react-intl", { 5 | "messagesDir": "./translations/messages/" 6 | }] 7 | ], 8 | "presets": [["@babel/preset-env", {"targets": {"browsers": ["last 3 versions", "Safari >= 8", "iOS >= 8"]}}], "@babel/preset-react"] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.{yml,json,json5}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | dist/* 3 | playground/ 4 | scripts/* 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['scratch', 'scratch/es6', 'scratch/node'] 3 | }; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly specify line endings for as many files as possible. 5 | # People who (for example) rsync between Windows and Linux need this. 6 | 7 | # File types which we know are binary 8 | *.sb2 binary 9 | 10 | # Prefer LF for most file types 11 | *.css text eol=lf 12 | *.frag text eol=lf 13 | *.htm text eol=lf 14 | *.html text eol=lf 15 | *.iml text eol=lf 16 | *.js text eol=lf 17 | *.js.map text eol=lf 18 | *.json text eol=lf 19 | *.json5 text eol=lf 20 | *.md text eol=lf 21 | *.vert text eol=lf 22 | *.xml text eol=lf 23 | *.yml text eol=lf 24 | 25 | # Prefer LF for these files 26 | .editorconfig text eol=lf 27 | .eslintignore text eol=lf 28 | .eslintrc text eol=lf 29 | .gitattributes text eol=lf 30 | .gitignore text eol=lf 31 | .gitmodules text eol=lf 32 | .npmignore text eol=lf 33 | LICENSE text eol=lf 34 | Makefile text eol=lf 35 | README text eol=lf 36 | TRADEMARK text eol=lf 37 | 38 | # Use CRLF for Windows-specific file types 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | _Please describe what should happen_ 4 | 5 | ### Actual Behavior 6 | 7 | _Describe what actually happens_ 8 | 9 | ### Steps to Reproduce 10 | 11 | _Explain what someone needs to do in order to see what's described in *Actual behavior* above_ 12 | 13 | ### Operating System and Browser 14 | 15 | _e.g. Mac OS 10.11.6 Safari 10.0_ -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Resolves 2 | 3 | _What Github issue does this resolve (please include link)?_ 4 | 5 | ### Proposed Changes 6 | 7 | _Describe what this Pull Request does_ 8 | 9 | ### Reason for Changes 10 | 11 | _Explain why these changes should be made_ 12 | 13 | ### Test Coverage 14 | 15 | _Please show how you have added tests to cover your changes_ -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Scratch Paint CI-CD 2 | 3 | on: 4 | pull_request: # Runs whenever a pull request is created or updated 5 | push: # Runs whenever a commit is pushed to the repository... 6 | branches: [master, develop, beta, hotfix/*] # ...on any of these branches 7 | 8 | concurrency: 9 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: write 14 | pages: write 15 | issues: write 16 | pull-requests: write 17 | 18 | jobs: 19 | ci-cd: 20 | runs-on: ubuntu-latest 21 | env: 22 | TRIGGER_DEPLOY: ${{ startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/hotfix') || startsWith(github.ref, 'refs/heads/develop') || startsWith(github.ref, 'refs/heads/beta') }} 23 | steps: 24 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 25 | - uses: wagoid/commitlint-github-action@5ce82f5d814d4010519d15f0552aec4f17a1e1fe # v5 26 | if: github.event_name == 'pull_request' 27 | - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3 28 | with: 29 | cache: "npm" 30 | node-version-file: ".nvmrc" 31 | - name: Info 32 | run: | 33 | cat <.json 6 | source_file = translations/en.json 7 | source_lang = en 8 | type = CHROME 9 | -------------------------------------------------------------------------------- /TRADEMARK: -------------------------------------------------------------------------------- 1 | The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission. 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | ignores: [message => message.startsWith('chore(release):')] 4 | }; 5 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'scratch-semantic-release-config', 3 | branches: [ 4 | { 5 | name: 'develop' 6 | // default channel 7 | }, 8 | { 9 | name: 'hotfix/*', 10 | channel: 'hotfix' 11 | }, 12 | { 13 | name: 'beta', 14 | channel: 'beta', 15 | prerelease: true 16 | } 17 | ] 18 | }; 19 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | 4 | "extends": [ 5 | "github>scratchfoundation/scratch-renovate-config:js-lib-bundled" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /scripts/build-i18n-source.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const glob = require('glob'); 5 | const path = require('path'); 6 | const mkdirp = require('mkdirp'); 7 | 8 | var args = process.argv.slice(2); 9 | 10 | if (!args.length) { 11 | process.stdout.write('You must specify the messages dir generated by babel-plugin-react-intl.\n'); 12 | process.exit(1); 13 | } 14 | 15 | const MESSAGES_PATTERN = args.shift() + '/**/*.json'; 16 | 17 | if (!args.length) { 18 | process.stdout.write('A destination directory must be specified.\n'); 19 | process.exit(1); 20 | } 21 | 22 | const LANG_DIR = args.shift(); 23 | 24 | // Aggregates the default messages that were extracted from the example app's 25 | // React components via the React Intl Babel plugin. An error will be thrown if 26 | // there are messages in different components that use the same `id`. The result 27 | // is a chromei18n format collection of `id: {message: defaultMessage, 28 | // description: description}` pairs for the app's default locale. 29 | let defaultMessages = glob.sync(MESSAGES_PATTERN) 30 | .map((filename) => fs.readFileSync(filename, 'utf8')) 31 | .map((file) => JSON.parse(file)) 32 | .reduce((collection, descriptors) => { 33 | descriptors.forEach(({id, defaultMessage, description}) => { 34 | if (collection.hasOwnProperty(id)) { 35 | throw new Error(`Duplicate message id: ${id}`); 36 | } 37 | 38 | collection[id] = {message: defaultMessage, description: description}; 39 | }); 40 | 41 | return collection; 42 | }, {}); 43 | 44 | mkdirp.sync(LANG_DIR); 45 | fs.writeFileSync(path.join(LANG_DIR, 'en.json'), JSON.stringify(defaultMessages, null, 2)); 46 | -------------------------------------------------------------------------------- /src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | module.exports = { 3 | /* eslint-enable import/no-commonjs */ 4 | root: true, 5 | extends: ['scratch', 'scratch/es6', 'scratch/react', 'plugin:import/recommended'], 6 | env: { 7 | browser: true 8 | }, 9 | rules: { 10 | // BEGIN: these caused trouble after upgrading eslint-plugin-react from 7.20.3 to 7.33.2 11 | 'react/forbid-prop-types': 'off', 12 | 'react/no-unknown-property': 'off', 13 | // END: these caused trouble after upgrading eslint-plugin-react from 7.20.3 to 7.33.2 14 | 'import/no-mutable-exports': 'error', 15 | 'import/no-commonjs': 'error', 16 | 'import/no-amd': 'error', 17 | 'import/no-nodejs-modules': 'error' 18 | }, 19 | settings: { 20 | react: { 21 | version: '16.2' // Prevent 16.3 lifecycle method errors 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/bit-brush-mode/bit-brush-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | 6 | import brushIcon from './brush.svg'; 7 | 8 | const BitBrushModeComponent = props => ( 9 | 15 | ); 16 | 17 | BitBrushModeComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BitBrushModeComponent; 23 | -------------------------------------------------------------------------------- /src/components/bit-brush-mode/brush.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | brush 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-eraser-mode/bit-eraser-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import messages from '../../lib/messages.js'; 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | 6 | import eraserIcon from './eraser.svg'; 7 | 8 | const BitEraserComponent = props => ( 9 | 15 | ); 16 | 17 | BitEraserComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BitEraserComponent; 23 | -------------------------------------------------------------------------------- /src/components/bit-eraser-mode/eraser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | eraser 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-fill-mode/bit-fill-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | import messages from '../../lib/messages.js'; 6 | import fillIcon from './fill.svg'; 7 | 8 | const BitFillComponent = props => ( 9 | 15 | ); 16 | 17 | BitFillComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BitFillComponent; 23 | -------------------------------------------------------------------------------- /src/components/bit-fill-mode/fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fill 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-line-mode/bit-line-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import lineIcon from './line.svg'; 6 | 7 | const BitLineComponent = props => ( 8 | 14 | ); 15 | 16 | BitLineComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default BitLineComponent; 22 | -------------------------------------------------------------------------------- /src/components/bit-line-mode/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | line 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-oval-mode/bit-oval-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import ovalIcon from './oval.svg'; 6 | 7 | const BitOvalComponent = props => ( 8 | 14 | ); 15 | 16 | BitOvalComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default BitOvalComponent; 22 | -------------------------------------------------------------------------------- /src/components/bit-oval-mode/oval-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | oval-outlined 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/bit-oval-mode/oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | oval 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-rect-mode/bit-rect-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | import messages from '../../lib/messages.js'; 6 | import rectIcon from './rectangle.svg'; 7 | 8 | const BitRectComponent = props => ( 9 | 15 | ); 16 | 17 | BitRectComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BitRectComponent; 23 | -------------------------------------------------------------------------------- /src/components/bit-rect-mode/rectangle-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rectange-outlined 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-rect-mode/rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rectange 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-select-mode/bit-select-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import selectIcon from './marquee.svg'; 6 | 7 | const BitSelectComponent = props => ( 8 | 14 | ); 15 | 16 | BitSelectComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default BitSelectComponent; 22 | -------------------------------------------------------------------------------- /src/components/bit-select-mode/marquee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | marquee 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/bit-text-mode/bit-text-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | import messages from '../../lib/messages.js'; 6 | import textIcon from './text.svg'; 7 | 8 | const BitTextComponent = props => ( 9 | 15 | ); 16 | 17 | BitTextComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default BitTextComponent; 23 | -------------------------------------------------------------------------------- /src/components/bit-text-mode/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | text 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/brush-mode/brush-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import brushIcon from './brush.svg'; 6 | 7 | const BrushModeComponent = props => ( 8 | 14 | ); 15 | 16 | BrushModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default BrushModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/brush-mode/brush.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | brush 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/button-group/button-group.css: -------------------------------------------------------------------------------- 1 | @import "../../css/units"; 2 | 3 | .button-group { 4 | display: inline-flex; 5 | flex-direction: row; 6 | padding: 0 $grid-unit; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/button-group/button-group.jsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import styles from './button-group.css'; 6 | 7 | const ButtonGroup = props => ( 8 |
9 | {props.children} 10 |
11 | ); 12 | 13 | ButtonGroup.propTypes = { 14 | children: PropTypes.node.isRequired, 15 | className: PropTypes.string 16 | }; 17 | 18 | export default ButtonGroup; 19 | -------------------------------------------------------------------------------- /src/components/button/button.css: -------------------------------------------------------------------------------- 1 | @import "../../css/colors.css"; 2 | 3 | .button { 4 | background: none; 5 | cursor: pointer; 6 | user-select: none; 7 | } 8 | .button:active { 9 | background-color: $looks-transparent; 10 | } 11 | .highlighted.button { 12 | background-color: $looks-transparent; 13 | } 14 | .mod-disabled { 15 | cursor: auto; 16 | opacity: .5; 17 | } 18 | .mod-disabled:active { 19 | background: none; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/button/button.jsx: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See #13 */ 4 | 5 | /* ACTUALLY, THIS IS EDITED ;) 6 | THIS WAS CHANGED ON 10/25/2017 BY @mewtaylor TO ADD HANDLING FOR DISABLED STATES.*/ 7 | 8 | import classNames from 'classnames'; 9 | import PropTypes from 'prop-types'; 10 | import React from 'react'; 11 | 12 | import styles from './button.css'; 13 | 14 | const ButtonComponent = ({ 15 | className, 16 | highlighted, 17 | onClick, 18 | children, 19 | ...props 20 | }) => { 21 | const disabled = props.disabled || false; 22 | if (disabled === false) { 23 | // if not disabled, add `onClick()` to be applied 24 | // in props. If disabled, don't add `onClick()` 25 | props.onClick = onClick; 26 | } 27 | return ( 28 | 40 | {children} 41 | 42 | ); 43 | }; 44 | 45 | ButtonComponent.propTypes = { 46 | children: PropTypes.node, 47 | className: PropTypes.string, 48 | disabled: PropTypes.oneOfType([ 49 | PropTypes.string, 50 | PropTypes.bool 51 | ]), 52 | highlighted: PropTypes.bool, 53 | onClick: PropTypes.func.isRequired 54 | }; 55 | export default ButtonComponent; 56 | -------------------------------------------------------------------------------- /src/components/color-button/color-button.css: -------------------------------------------------------------------------------- 1 | .color-button { 2 | height: 2rem; 3 | width: 3rem; 4 | display: flex; 5 | } 6 | 7 | .color-button-swatch { 8 | position: relative; 9 | display: flex; 10 | cursor: pointer; 11 | flex-basis: 2rem; 12 | flex-shrink: 0; 13 | height: 100%; 14 | border: 1px solid rgba(0, 0, 0, 0.25); 15 | } 16 | 17 | [dir="ltr"] .color-button-swatch { 18 | border-top-left-radius: 4px; 19 | border-bottom-left-radius: 4px; 20 | } 21 | 22 | [dir="rtl"] .color-button-swatch { 23 | border-top-right-radius: 4px; 24 | border-bottom-right-radius: 4px; 25 | } 26 | 27 | .color-button-arrow { 28 | display: flex; 29 | user-select: none; 30 | cursor: pointer; 31 | flex-basis: 1rem; 32 | flex-shrink: 0; 33 | height: 100%; 34 | 35 | border: 1px solid rgba(0, 0, 0, 0.25); 36 | 37 | align-items: center; 38 | justify-content: center; 39 | color: #575e75; 40 | font-size: 0.75rem; 41 | } 42 | 43 | [dir="ltr"] .color-button-arrow { 44 | border-top-right-radius: 4px; 45 | border-bottom-right-radius: 4px; 46 | border-left: none; 47 | } 48 | 49 | [dir="rtl"] .color-button-arrow { 50 | border-top-left-radius: 4px; 51 | border-bottom-left-radius: 4px; 52 | border-right: none; 53 | } 54 | 55 | .swatch-icon { 56 | width: 1.75rem; 57 | margin: auto; 58 | /* Make sure it appears above the outline box */ 59 | z-index: 2; 60 | } 61 | 62 | .outline-swatch:after { 63 | content: ""; 64 | position: absolute; 65 | top: calc(0.5rem); 66 | left: calc(0.5rem); 67 | width: 0.75rem; 68 | height: 0.75rem; 69 | background: white; 70 | border: 1px solid rgba(0, 0, 0, 0.25); 71 | /* Make sure it appears below the transparent icon */ 72 | z-index: 1; 73 | } 74 | -------------------------------------------------------------------------------- /src/components/color-button/color-button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | import {MIXED} from '../../helper/style-path'; 6 | 7 | import noFillIcon from './no-fill.svg'; 8 | import mixedFillIcon from './mixed-fill.svg'; 9 | import styles from './color-button.css'; 10 | import GradientTypes from '../../lib/gradient-types'; 11 | import log from '../../log/log'; 12 | 13 | const colorToBackground = (color, color2, gradientType) => { 14 | if (color === MIXED || (gradientType !== GradientTypes.SOLID && color2 === MIXED)) return 'white'; 15 | if (color === null) color = 'white'; 16 | if (color2 === null) color2 = 'white'; 17 | switch (gradientType) { 18 | case GradientTypes.SOLID: return color; 19 | case GradientTypes.HORIZONTAL: return `linear-gradient(to right, ${color}, ${color2})`; 20 | case GradientTypes.VERTICAL: return `linear-gradient(${color}, ${color2})`; 21 | case GradientTypes.RADIAL: return `radial-gradient(${color}, ${color2})`; 22 | default: log.error(`Unrecognized gradient type: ${gradientType}`); 23 | } 24 | }; 25 | 26 | const ColorButtonComponent = props => ( 27 |
31 |
39 | {props.color === null && (props.gradientType === GradientTypes.SOLID || props.color2 === null) ? ( 40 | 45 | ) : ((props.color === MIXED || (props.gradientType !== GradientTypes.SOLID && props.color2 === MIXED) ? ( 46 | 51 | ) : null))} 52 |
53 |
54 |
55 | ); 56 | 57 | ColorButtonComponent.propTypes = { 58 | color: PropTypes.string, 59 | color2: PropTypes.string, 60 | gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, 61 | onClick: PropTypes.func.isRequired, 62 | outline: PropTypes.bool.isRequired 63 | }; 64 | 65 | ColorButtonComponent.defaultProps = { 66 | outline: false 67 | }; 68 | 69 | export default ColorButtonComponent; 70 | -------------------------------------------------------------------------------- /src/components/color-button/mixed-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mixed-fill 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/color-button/no-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | no-fill 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/color-indicator.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Popover from 'react-popover'; 4 | 5 | import ColorButton from './color-button/color-button.jsx'; 6 | import ColorPicker from '../containers/color-picker.jsx'; 7 | import InputGroup from './input-group/input-group.jsx'; 8 | import Label from './forms/label.jsx'; 9 | 10 | import GradientTypes from '../lib/gradient-types'; 11 | 12 | const ColorIndicatorComponent = props => ( 13 | 17 | 28 | } 29 | isOpen={props.colorModalVisible} 30 | preferPlace="below" 31 | onOuterAction={props.onCloseColor} 32 | > 33 | 42 | 43 | 44 | ); 45 | 46 | ColorIndicatorComponent.propTypes = { 47 | className: PropTypes.string, 48 | disabled: PropTypes.bool.isRequired, 49 | color: PropTypes.string, 50 | color2: PropTypes.string, 51 | colorModalVisible: PropTypes.bool.isRequired, 52 | gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, 53 | label: PropTypes.string.isRequired, 54 | onChangeColor: PropTypes.func.isRequired, 55 | onChangeGradientType: PropTypes.func.isRequired, 56 | onCloseColor: PropTypes.func.isRequired, 57 | onOpenColor: PropTypes.func.isRequired, 58 | onSwap: PropTypes.func.isRequired, 59 | outline: PropTypes.bool.isRequired, 60 | shouldShowGradientTools: PropTypes.bool.isRequired 61 | }; 62 | 63 | export default ColorIndicatorComponent; 64 | -------------------------------------------------------------------------------- /src/components/color-picker/color-picker.css: -------------------------------------------------------------------------------- 1 | @import "../../css/colors"; 2 | @import "../../css/units"; 3 | 4 | /* Popover styles */ 5 | :global(.Popover-body) { 6 | background: white; 7 | border: 1px solid #ddd; 8 | padding: 4px; 9 | border-radius: 4px; 10 | padding: 4px; 11 | box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, .3); 12 | } 13 | 14 | :global(.Popover-tipShape) { 15 | fill: white; 16 | stroke: #ddd; 17 | } 18 | 19 | .clickable { 20 | cursor: pointer; 21 | } 22 | 23 | .swatch-row { 24 | display: flex; 25 | flex-direction: row; 26 | justify-content: space-between; 27 | } 28 | 29 | .row-header { 30 | font-family: "Helvetica Neue", Helvetica, sans-serif; 31 | font-size: 0.65rem; 32 | color: #575E75; 33 | margin: 8px; 34 | } 35 | 36 | [dir="ltr"] .label-readout { 37 | margin-left: 10px; 38 | } 39 | 40 | [dir="rtl"] .label-readout { 41 | margin-right: 10px; 42 | } 43 | 44 | .label-name { 45 | font-weight: bold; 46 | } 47 | 48 | .divider { 49 | border-top: 1px solid #ddd; 50 | margin: 8px; 51 | } 52 | 53 | .swap-button { 54 | margin-left: 8px; 55 | margin-right: 8px; 56 | } 57 | 58 | .swatches { 59 | margin: 8px; 60 | } 61 | 62 | .swatch { 63 | width: 1.5rem; 64 | height: 1.5rem; 65 | border: 1px solid #ddd; 66 | border-radius: 4px; 67 | box-sizing: content-box; 68 | display: flex; 69 | align-items: center; 70 | } 71 | 72 | .large-swatch-icon { 73 | width: 1.75rem; 74 | margin: auto; 75 | } 76 | 77 | .large-swatch { 78 | width: 2rem; 79 | height: 2rem; 80 | } 81 | 82 | .active-swatch { 83 | border: 1px solid $looks-secondary; 84 | box-shadow: 0px 0px 0px 3px $looks-transparent; 85 | } 86 | 87 | .swatch-icon { 88 | width: 1.5rem; 89 | height: 1.5rem; 90 | } 91 | 92 | .inactive-gradient { 93 | filter: saturate(0%); 94 | } 95 | 96 | .gradient-picker-row { 97 | align-items: center; 98 | display: flex; 99 | flex-direction: row; 100 | justify-content: center; 101 | margin: 8px; 102 | user-select: none; 103 | } 104 | 105 | [dir="ltr"] .gradient-picker-row > img + img { 106 | margin-left: calc(2 * $grid-unit); 107 | } 108 | 109 | [dir="rtl"] .gradient-picker-row > img + img { 110 | margin-right: calc(2 * $grid-unit); 111 | } 112 | 113 | [dir="rtl"] .gradient-swatches-row { 114 | flex-direction: row-reverse; 115 | } 116 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/eye-dropper.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | eye-dropper 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/fill-horz-gradient-enabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fill-horz-gradient-enabled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/fill-radial-enabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fill-radial-enabled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/fill-solid-enabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fill-solid-enabled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/fill-vert-gradient-enabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fill-vert-gradient-enabled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/color-picker/icons/swap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | swap 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/coming-soon/aww-cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-paint/f0f9e95bbf130d6a7e644ed1e8f02a93b5ad9481/src/components/coming-soon/aww-cat.png -------------------------------------------------------------------------------- /src/components/coming-soon/coming-soon.css: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See #13 */ 4 | 5 | /* 6 | * NOTE: the copious use of `important` is needed to overwrite 7 | * the default tooltip styling, and is required by the 3rd party 8 | * library being used, `react-tooltip` 9 | */ 10 | 11 | @import "../../css/colors.css"; 12 | 13 | .coming-soon { 14 | background-color: $data-primary !important; 15 | border: 1px solid hsla(0, 0%, 0%, .1) !important; 16 | border-radius: .25rem !important; 17 | box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important; 18 | padding: .75rem 1rem !important; 19 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; 20 | font-size: 1rem !important; 21 | line-height: 1.25rem !important; 22 | z-index: 100 !important; 23 | } 24 | 25 | .coming-soon:after { 26 | content: ""; 27 | border-top: 1px solid hsla(0, 0%, 0%, .1) !important; 28 | border-left: 0 !important; 29 | border-bottom: 0 !important; 30 | border-right: 1px solid hsla(0, 0%, 0%, .1) !important; 31 | border-radius: .25rem; 32 | background-color: $data-primary !important; 33 | height: 1rem !important; 34 | width: 1rem !important; 35 | } 36 | 37 | .show, 38 | .show:before, 39 | .show:after { 40 | opacity: 1 !important; 41 | } 42 | 43 | .left:after { 44 | margin-top: -.5rem !important; 45 | right: -.5rem !important; 46 | transform: rotate(45deg) !important; 47 | } 48 | 49 | .right:after { 50 | margin-top: -.5rem !important; 51 | left: -.5rem !important; 52 | transform: rotate(-135deg) !important; 53 | } 54 | 55 | .top:after { 56 | margin-right: -.5rem !important; 57 | bottom: -.5rem !important; 58 | transform: rotate(135deg) !important; 59 | } 60 | 61 | .bottom:after { 62 | margin-left: -.5rem !important; 63 | top: -.5rem !important; 64 | transform: rotate(-45deg) !important; 65 | } 66 | 67 | .coming-soon-image { 68 | margin-left: .125rem; 69 | width: 1.25rem; 70 | height: 1.25rem; 71 | vertical-align: middle; 72 | } 73 | -------------------------------------------------------------------------------- /src/components/coming-soon/cool-cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-paint/f0f9e95bbf130d6a7e644ed1e8f02a93b5ad9481/src/components/coming-soon/cool-cat.png -------------------------------------------------------------------------------- /src/components/dropdown/dropdown-caret.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dropdown-caret 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/dropdown/dropdown.css: -------------------------------------------------------------------------------- 1 | @import '../../css/colors.css'; 2 | 3 | $arrow-border-width: 14px; 4 | 5 | .dropdown { 6 | border: 1px solid $form-border; 7 | border-radius: 5px; 8 | overflow: visible; 9 | min-width: 3.5rem; 10 | color: $looks-secondary; 11 | padding: .5rem; 12 | } 13 | 14 | .mod-open { 15 | background-color: $form-border; 16 | } 17 | 18 | .dropdown-icon { 19 | width: .5rem; 20 | height: .5rem; 21 | vertical-align: middle; 22 | padding-bottom: .2rem; 23 | } 24 | 25 | [dir="ltr"] .dropdown-icon { 26 | margin-left: .5rem; 27 | } 28 | 29 | [dir="rtl"] .dropdown-icon { 30 | margin-right: .5rem; 31 | } 32 | 33 | .mod-caret-up { 34 | transform: rotate(180deg); 35 | padding-bottom: 0; 36 | padding-top: .2rem; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/dropdown/dropdown.jsx: -------------------------------------------------------------------------------- 1 | import bindAll from 'lodash.bindall'; 2 | import classNames from 'classnames'; 3 | import Popover from 'react-popover'; 4 | import PropTypes from 'prop-types'; 5 | import React from 'react'; 6 | 7 | import styles from './dropdown.css'; 8 | 9 | import dropdownIcon from './dropdown-caret.svg'; 10 | 11 | class Dropdown extends React.Component { 12 | constructor (props) { 13 | super(props); 14 | bindAll(this, [ 15 | 'handleClosePopover', 16 | 'handleToggleOpenState', 17 | 'isOpen' 18 | ]); 19 | this.state = { 20 | isOpen: false 21 | }; 22 | } 23 | handleClosePopover () { 24 | this.setState({ 25 | isOpen: false 26 | }); 27 | } 28 | handleToggleOpenState () { 29 | const newState = !this.state.isOpen; 30 | this.setState({ 31 | isOpen: newState 32 | }); 33 | if (newState && this.props.onOpen) { 34 | this.props.onOpen(); 35 | } 36 | } 37 | isOpen () { 38 | return this.state.isOpen; 39 | } 40 | render () { 41 | return ( 42 | 50 |
57 | {this.props.children} 58 | 65 |
66 |
67 | ); 68 | } 69 | } 70 | 71 | Dropdown.propTypes = { 72 | children: PropTypes.node.isRequired, 73 | className: PropTypes.string, 74 | onOpen: PropTypes.func, 75 | onOuterAction: PropTypes.func, 76 | popoverContent: PropTypes.node.isRequired 77 | }; 78 | 79 | export default Dropdown; 80 | -------------------------------------------------------------------------------- /src/components/eraser-mode/eraser-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import eraserIcon from './eraser.svg'; 6 | 7 | const EraserModeComponent = props => ( 8 | 14 | ); 15 | 16 | EraserModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default EraserModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/eraser-mode/eraser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | eraser 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/fill-mode/fill-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import fillIcon from './fill.svg'; 6 | 7 | const FillModeComponent = props => ( 8 | 14 | ); 15 | 16 | FillModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default FillModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | group 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | redo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/send-back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | send-back 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/send-backward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | send-backward 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/send-forward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | send-forward 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/send-front.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | send-front 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | undo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/fixed-tools/icons/ungroup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ungroup 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/font-dropdown/font-dropdown.css: -------------------------------------------------------------------------------- 1 | @import "../../css/colors.css"; 2 | @import "../../css/units.css"; 3 | 4 | .mod-menu-item { 5 | display: flex; 6 | margin: 0 -$grid-unit; 7 | min-width: 6.25rem; 8 | padding: calc(2 * $grid-unit); 9 | padding-left: calc(3 * $grid-unit); 10 | padding-right: calc(3 * $grid-unit); 11 | white-space: nowrap; 12 | width: 8.5rem; 13 | cursor: pointer; 14 | transition: 0.1s ease; 15 | align-items: center; 16 | } 17 | 18 | .mod-menu-item:hover { 19 | background: $looks-secondary; 20 | color: white; 21 | } 22 | 23 | .mod-context-menu { 24 | display: flex; 25 | flex-direction: column; 26 | } 27 | 28 | .mod-unselect { 29 | user-select: none; 30 | } 31 | 32 | .displayed-font-name { 33 | font-size: .8rem; 34 | } 35 | 36 | .font-dropdown { 37 | align-items: center; 38 | color: $text-primary; 39 | display: flex; 40 | font-size: 1rem; 41 | justify-content: space-between; 42 | width: 8.5rem; 43 | height: 2rem; 44 | } 45 | 46 | .serif { 47 | font-family: 'Serif'; 48 | } 49 | 50 | .sans-serif { 51 | font-family: 'Sans Serif'; 52 | } 53 | 54 | .serif { 55 | font-family: 'Serif'; 56 | } 57 | 58 | .handwriting { 59 | font-family: 'Handwriting'; 60 | } 61 | 62 | .marker { 63 | font-family: 'Marker'; 64 | } 65 | 66 | .curly { 67 | font-family: 'Curly'; 68 | } 69 | 70 | .pixel { 71 | font-family: 'Pixel'; 72 | } 73 | 74 | .chinese { 75 | font-family: "Microsoft YaHei", "微软雅黑", STXihei, "华文细黑"; 76 | } 77 | 78 | .japanese { 79 | font-family: "ヒラギノ角ゴ Pro W3", "Hiragino Kaku Gothic Pro", Osaka, "メイリオ", Meiryo, "MS Pゴシック", "MS PGothic"; 80 | } 81 | 82 | .korean { 83 | font-family: "Malgun Gothic"; 84 | } 85 | -------------------------------------------------------------------------------- /src/components/forms/buffered-input-hoc.jsx: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | import bindAll from 'lodash.bindall'; 6 | import PropTypes from 'prop-types'; 7 | import React from 'react'; 8 | 9 | /** 10 | * Higher Order Component to manage inputs that submit on blur and 11 | * @param {React.Component} Input text input that consumes onChange, onBlur, onKeyPress 12 | * @returns {React.Component} Buffered input that calls onSubmit on blur and 13 | */ 14 | export default function (Input) { 15 | class BufferedInput extends React.Component { 16 | constructor (props) { 17 | super(props); 18 | bindAll(this, [ 19 | 'handleChange', 20 | 'handleKeyPress', 21 | 'handleFlush' 22 | ]); 23 | this.state = { 24 | value: null 25 | }; 26 | } 27 | handleKeyPress (e) { 28 | if (e.key === 'Enter') { 29 | this.handleFlush(); 30 | e.target.blur(); 31 | } 32 | } 33 | handleFlush () { 34 | const isNumeric = typeof this.props.value === 'number'; 35 | const validatesNumeric = isNumeric ? !isNaN(this.state.value) : true; 36 | if (this.state.value !== null && validatesNumeric) { 37 | this.props.onSubmit(isNumeric ? Number(this.state.value) : this.state.value); 38 | } 39 | this.setState({value: null}); 40 | } 41 | handleChange (e) { 42 | this.setState({value: e.target.value}); 43 | } 44 | render () { 45 | const bufferedValue = this.state.value === null ? this.props.value : this.state.value; 46 | return ( 47 | 54 | ); 55 | } 56 | } 57 | 58 | BufferedInput.propTypes = { 59 | onSubmit: PropTypes.func.isRequired, 60 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) 61 | }; 62 | 63 | return BufferedInput; 64 | } 65 | -------------------------------------------------------------------------------- /src/components/forms/input.css: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | /* NOTE: 6 | Edited to add input-range-small 7 | */ 8 | 9 | @import "../../css/units.css"; 10 | @import "../../css/colors.css"; 11 | 12 | .input-form { 13 | height: 2rem; 14 | padding: 0 0.75rem; 15 | 16 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 17 | font-size: 0.75rem; 18 | font-weight: bold; 19 | color: $text-primary; 20 | 21 | border-width: 1px; 22 | border-style: solid; 23 | border-color: $form-border; 24 | border-radius: 2rem; 25 | 26 | outline: none; 27 | cursor: text; 28 | transition: 0.25s ease-out; /* @todo: standardize with var */ 29 | box-shadow: none; 30 | 31 | /* 32 | For truncating overflowing text gracefully 33 | Min-width is for a bug: https://css-tricks.com/flexbox-truncated-text 34 | @todo: move this out into a mixin or a helper component 35 | */ 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | white-space: nowrap; 39 | min-width: 0; 40 | } 41 | 42 | .input-form:focus { 43 | border-color: $looks-secondary; 44 | box-shadow: 0 0 0 $grid-unit $looks-transparent; 45 | } 46 | 47 | .input-small { 48 | width: 3rem; 49 | text-align: center; 50 | } 51 | 52 | .input-small-range { 53 | width: 4rem; 54 | text-align: center; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/forms/input.jsx: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | /* NOTE: 6 | Edited to add range prop 7 | */ 8 | 9 | import PropTypes from 'prop-types'; 10 | import React from 'react'; 11 | import classNames from 'classnames'; 12 | 13 | import styles from './input.css'; 14 | 15 | const Input = props => { 16 | const {small, range, ...componentProps} = props; 17 | return ( 18 | 29 | ); 30 | }; 31 | 32 | Input.propTypes = { 33 | className: PropTypes.string, 34 | range: PropTypes.bool, 35 | small: PropTypes.bool 36 | }; 37 | 38 | Input.defaultProps = { 39 | range: false, 40 | small: false 41 | }; 42 | 43 | export default Input; 44 | -------------------------------------------------------------------------------- /src/components/forms/label.css: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | @import "../../css/units.css"; 6 | @import "../../css/colors.css"; 7 | @import "../input-group/input-group.css"; 8 | 9 | .input-label, .input-label-secondary { 10 | font-size: 0.625rem; 11 | user-select: none; 12 | cursor: default; 13 | } 14 | 15 | [dir="ltr"] .input-label, [dir="ltr"] .input-label-secondary{ 16 | margin-right: calc(2 * $grid-unit); 17 | } 18 | 19 | [dir="rtl"] .input-label, [dir="ltr"] .input-label-secondary{ 20 | margin-left: calc(2 * $grid-unit); 21 | } 22 | 23 | .input-label { 24 | font-weight: bold; 25 | } 26 | 27 | @media only screen and (max-width: $full-size-paint) { 28 | .input-group { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: flex-start; 32 | margin-top: -1rem; /* To align with the non-labeled inputs */ 33 | } 34 | 35 | .input-label { 36 | font-weight: normal; 37 | margin-bottom: 0.25rem; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/forms/label.jsx: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | import PropTypes from 'prop-types'; 6 | import React from 'react'; 7 | 8 | import styles from './label.css'; 9 | 10 | const Label = props => ( 11 | 17 | ); 18 | 19 | Label.propTypes = { 20 | children: PropTypes.node, 21 | secondary: PropTypes.bool, 22 | text: PropTypes.string.isRequired 23 | }; 24 | 25 | Label.defaultProps = { 26 | secondary: false 27 | }; 28 | 29 | export default Label; 30 | -------------------------------------------------------------------------------- /src/components/forms/live-input-hoc.jsx: -------------------------------------------------------------------------------- 1 | import bindAll from 'lodash.bindall'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | 5 | /** 6 | * Higher Order Component to manage inputs that submit on change and 7 | * @param {React.Component} Input text input that consumes onChange, onBlur, onKeyPress 8 | * @returns {React.Component} Live input that calls onSubmit on change and 9 | */ 10 | export default function (Input) { 11 | class LiveInput extends React.Component { 12 | constructor (props) { 13 | super(props); 14 | bindAll(this, [ 15 | 'handleChange', 16 | 'handleKeyPress', 17 | 'handleFlush' 18 | ]); 19 | this.state = { 20 | value: null 21 | }; 22 | } 23 | handleKeyPress (e) { 24 | if (e.key === 'Enter') { 25 | this.handleChange(e); 26 | e.target.blur(); 27 | } 28 | } 29 | handleFlush () { 30 | this.setState({value: null}); 31 | } 32 | handleChange (e) { 33 | const isNumeric = typeof this.props.value === 'number'; 34 | const validatesNumeric = isNumeric ? !isNaN(e.target.value) : true; 35 | if (e.target.value !== null && validatesNumeric) { 36 | let val = Number(e.target.value); 37 | if (typeof this.props.max !== 'undefined' && val > Number(this.props.max)) { 38 | val = this.props.max; 39 | } 40 | if (typeof this.props.min !== 'undefined' && val < Number(this.props.min)) { 41 | val = this.props.min; 42 | } 43 | this.props.onSubmit(val); 44 | } 45 | this.setState({value: e.target.value}); 46 | } 47 | render () { 48 | const liveValue = this.state.value === null ? this.props.value : this.state.value; 49 | return ( 50 | 57 | ); 58 | } 59 | } 60 | 61 | LiveInput.propTypes = { 62 | max: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 63 | min: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 64 | onSubmit: PropTypes.func.isRequired, 65 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) 66 | }; 67 | 68 | return LiveInput; 69 | } 70 | -------------------------------------------------------------------------------- /src/components/forms/slider.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 8px; 3 | height: 22px; 4 | width: 150px; 5 | position: relative; 6 | outline: none; 7 | border-radius: 11px; 8 | margin-bottom: 20px; 9 | } 10 | 11 | .last { 12 | margin-bottom: 4px; 13 | } 14 | 15 | .handle { 16 | left: 100px; 17 | width: 26px; 18 | height: 26px; 19 | margin-top: -2px; 20 | position: absolute; 21 | background-color: white; 22 | border-radius: 100%; 23 | box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.15); 24 | touch-action: none; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/input-group/input-group.css: -------------------------------------------------------------------------------- 1 | @import '../../css/units.css'; 2 | 3 | .input-group { 4 | display: inline-flex; 5 | flex-direction: row; 6 | align-items: center; 7 | } 8 | 9 | [dir="ltr"] .input-group + .input-group { 10 | margin-left: calc(2 * $grid-unit); 11 | } 12 | 13 | [dir="rtl"] .input-group + .input-group { 14 | margin-right: calc(2 * $grid-unit); 15 | } 16 | 17 | .disabled { 18 | opacity: 0.3; 19 | /* Prevent any user actions */ 20 | pointer-events: none; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/input-group/input-group.jsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import styles from './input-group.css'; 6 | 7 | const InputGroup = props => ( 8 |
14 | {props.children} 15 |
16 | ); 17 | 18 | InputGroup.propTypes = { 19 | children: PropTypes.node.isRequired, 20 | className: PropTypes.string, 21 | disabled: PropTypes.bool, 22 | rtl: PropTypes.bool 23 | }; 24 | 25 | export default InputGroup; 26 | -------------------------------------------------------------------------------- /src/components/labeled-icon-button/labeled-icon-button.css: -------------------------------------------------------------------------------- 1 | @import "../../css/units.css"; 2 | 3 | $border-radius: 0.25rem; 4 | 5 | .mod-edit-field { 6 | background: none; 7 | border: none; 8 | display: inline-block; 9 | padding: .25rem .325rem; 10 | outline: none; 11 | border-radius: $border-radius; 12 | min-width: 3rem; 13 | font-size: 0.85rem; 14 | text-align: center; 15 | } 16 | 17 | .edit-field-icon { 18 | width: 1.5rem; 19 | height: 1.5rem; 20 | flex-grow: 1; 21 | vertical-align: middle; 22 | } 23 | 24 | .edit-field-title { 25 | display: block; 26 | margin-top: .125rem; 27 | font-size: .625rem; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/labeled-icon-button/labeled-icon-button.jsx: -------------------------------------------------------------------------------- 1 | /* @todo This file should be pulled out into a shared library with scratch-gui, 2 | consolidating this component with icon-button.jsx in gui. 3 | See #13 */ 4 | 5 | import classNames from 'classnames'; 6 | import React from 'react'; 7 | import PropTypes from 'prop-types'; 8 | 9 | import Button from '../button/button.jsx'; 10 | 11 | import styles from './labeled-icon-button.css'; 12 | 13 | const LabeledIconButton = ({ 14 | className, 15 | hideLabel, 16 | imgAlt, 17 | imgSrc, 18 | onClick, 19 | title, 20 | ...props 21 | }) => ( 22 | 36 | ); 37 | 38 | LabeledIconButton.propTypes = { 39 | className: PropTypes.string, 40 | hideLabel: PropTypes.bool, 41 | highlighted: PropTypes.bool, 42 | imgAlt: PropTypes.string, 43 | imgSrc: PropTypes.string.isRequired, 44 | onClick: PropTypes.func.isRequired, 45 | title: PropTypes.string.isRequired 46 | }; 47 | 48 | export default LabeledIconButton; 49 | -------------------------------------------------------------------------------- /src/components/line-mode/line-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import lineIcon from './line.svg'; 6 | 7 | const LineModeComponent = props => ( 8 | 14 | ); 15 | 16 | LineModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default LineModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/line-mode/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | line 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/loupe/loupe.css: -------------------------------------------------------------------------------- 1 | .eye-dropper { 2 | position: absolute; 3 | border-radius: 100%; 4 | border: 1px solid #222; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | copy v2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/curved-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | curved-point 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | delete 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/flip-horizontal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | flip-horizontal 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/flip-vertical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | flip-vertical 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/paste.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | paste v2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/mode-tools/icons/straight-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | straight-point 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/mode-tools/mode-tools.css: -------------------------------------------------------------------------------- 1 | @import "../../css/colors.css"; 2 | @import "../../css/units.css"; 3 | 4 | .mode-tools { 5 | display: flex; 6 | min-height: 3rem; 7 | align-items: center; 8 | } 9 | 10 | .mode-tools-icon { 11 | margin-right: calc(2 * $grid-unit); 12 | width: 2rem; 13 | height: 2rem; 14 | } 15 | 16 | [dir="ltr"] .mod-dashed-border { 17 | border-right: 1px dashed $ui-pane-border; 18 | padding-right: calc(3 * $grid-unit); 19 | } 20 | 21 | [dir="rtl"] .mod-dashed-border { 22 | border-left: 1px dashed $ui-pane-border; 23 | padding-left: calc(3 * $grid-unit); 24 | } 25 | 26 | .mod-labeled-icon-height { 27 | display: flex; 28 | height: 2.85rem; /* for the second row so the dashed borders are equal in size */ 29 | align-items: center; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/oval-mode/oval-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import ovalIcon from './oval.svg'; 6 | 7 | const OvalModeComponent = props => ( 8 | 14 | ); 15 | 16 | OvalModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default OvalModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/oval-mode/oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | oval 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/paint-editor/icons/bitmap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | bitmap 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/paint-editor/icons/rotation-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rotation-point 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/paint-editor/icons/zoom-in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | zoom-in 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/paint-editor/icons/zoom-out.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | zoom-out 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/paint-editor/icons/zoom-reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | zoom-reset 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/rect-mode/rect-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 4 | import messages from '../../lib/messages.js'; 5 | import rectIcon from './rectangle.svg'; 6 | 7 | const RectModeComponent = props => ( 8 | 14 | ); 15 | 16 | RectModeComponent.propTypes = { 17 | isSelected: PropTypes.bool.isRequired, 18 | onMouseDown: PropTypes.func.isRequired 19 | }; 20 | 21 | export default RectModeComponent; 22 | -------------------------------------------------------------------------------- /src/components/rect-mode/rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rectangle 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/reshape-mode/reshape-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import messages from '../../lib/messages.js'; 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | 6 | import reshapeIcon from './reshape.svg'; 7 | 8 | const ReshapeModeComponent = props => ( 9 | 15 | ); 16 | 17 | ReshapeModeComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default ReshapeModeComponent; 23 | -------------------------------------------------------------------------------- /src/components/reshape-mode/reshape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | reshape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/rounded-rect-mode/rounded-rect-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import messages from '../../lib/messages.js'; 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | 6 | import roundedRectIcon from './rounded-rectangle.svg'; 7 | 8 | const RoundedRectModeComponent = props => ( 9 | 15 | ); 16 | 17 | RoundedRectModeComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default RoundedRectModeComponent; 23 | -------------------------------------------------------------------------------- /src/components/rounded-rect-mode/rounded-rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rounded-rectangle 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/scrollable-canvas/scrollable-canvas.css: -------------------------------------------------------------------------------- 1 | $scrollbar-size: 8px; 2 | $scrollbar-padding: 4px; 3 | 4 | .vertical-scrollbar, .horizontal-scrollbar { 5 | background: rgba(190, 190, 190, 0.8); 6 | border-radius: calc($scrollbar-size / 2); 7 | width: 100%; 8 | height: 100%; 9 | } 10 | .vertical-scrollbar-wrapper { 11 | position: absolute; 12 | width: calc($scrollbar-size + $scrollbar-padding); 13 | right: 0; 14 | top: $scrollbar-padding; 15 | height: calc(100% - $scrollbar-size - 2 * $scrollbar-padding); 16 | } 17 | 18 | .horizontal-scrollbar-wrapper { 19 | position: absolute; 20 | height: calc($scrollbar-size + $scrollbar-padding); 21 | left: $scrollbar-padding; 22 | bottom: 0; 23 | width: calc(100% - $scrollbar-size - 2 * $scrollbar-padding); 24 | } 25 | 26 | .vertical-scrollbar-hitbox, .horizontal-scrollbar-hitbox { 27 | position: absolute; 28 | cursor: pointer; 29 | box-sizing: border-box; 30 | } 31 | 32 | .vertical-scrollbar-hitbox { 33 | width: calc($scrollbar-size + $scrollbar-padding); 34 | padding-right: $scrollbar-padding; 35 | } 36 | 37 | .horizontal-scrollbar-hitbox { 38 | height: calc($scrollbar-size + $scrollbar-padding); 39 | padding-bottom: $scrollbar-padding; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/select-mode/select-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import messages from '../../lib/messages.js'; 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | 6 | import selectIcon from './select.svg'; 7 | 8 | const SelectModeComponent = props => ( 9 | 15 | ); 16 | 17 | SelectModeComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default SelectModeComponent; 23 | -------------------------------------------------------------------------------- /src/components/select-mode/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | select 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/stroke-width-indicator.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Input from './forms/input.jsx'; 5 | import InputGroup from './input-group/input-group.jsx'; 6 | import LiveInputHOC from './forms/live-input-hoc.jsx'; 7 | 8 | import {MAX_STROKE_WIDTH} from '../reducers/stroke-width'; 9 | 10 | const LiveInput = LiveInputHOC(Input); 11 | const StrokeWidthIndicatorComponent = props => ( 12 | 13 | 23 | 24 | ); 25 | 26 | StrokeWidthIndicatorComponent.propTypes = { 27 | disabled: PropTypes.bool.isRequired, 28 | onChangeStrokeWidth: PropTypes.func.isRequired, 29 | strokeWidth: PropTypes.number 30 | }; 31 | 32 | export default StrokeWidthIndicatorComponent; 33 | -------------------------------------------------------------------------------- /src/components/text-mode/text-mode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import messages from '../../lib/messages.js'; 4 | import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; 5 | 6 | import textIcon from './text.svg'; 7 | 8 | const TextModeComponent = props => ( 9 | 15 | ); 16 | 17 | TextModeComponent.propTypes = { 18 | isSelected: PropTypes.bool.isRequired, 19 | onMouseDown: PropTypes.func.isRequired 20 | }; 21 | 22 | export default TextModeComponent; 23 | -------------------------------------------------------------------------------- /src/components/text-mode/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | text 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/tool-select-base/tool-select-base.css: -------------------------------------------------------------------------------- 1 | @import '../../css/colors.css'; 2 | @import "../../css/units.css"; 3 | 4 | $border-radius: .25rem; 5 | 6 | .mod-tool-select { 7 | display: inline-block; 8 | margin: $grid-unit; 9 | border: none; 10 | border-radius: $border-radius; 11 | outline: none; 12 | background: none; 13 | padding: $grid-unit; 14 | font-size: 0.85rem; 15 | transition: 0.2s; 16 | } 17 | 18 | .mod-tool-select.is-selected { 19 | background-color: $looks-secondary; 20 | } 21 | 22 | .mod-tool-select:focus { 23 | outline: none; 24 | } 25 | 26 | img.tool-select-icon { 27 | width: 2rem; 28 | height: 2rem; 29 | flex-grow: 1; 30 | vertical-align: middle; 31 | } 32 | 33 | .mod-tool-select.is-selected .tool-select-icon { 34 | /* Make the tool icons white while selected by making them black and inverting */ 35 | filter: brightness(0) invert(1); 36 | } 37 | 38 | @media only screen and (max-width: $full-size-paint) { 39 | .mod-tool-select { 40 | margin: 0; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/tool-select-base/tool-select-base.jsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import {injectIntl, intlShape} from 'react-intl'; 5 | 6 | import Button from '../button/button.jsx'; 7 | 8 | import styles from './tool-select-base.css'; 9 | 10 | const ToolSelectComponent = props => ( 11 | 28 | ); 29 | 30 | ToolSelectComponent.propTypes = { 31 | className: PropTypes.string, 32 | disabled: PropTypes.bool, 33 | imgDescriptor: PropTypes.shape({ 34 | defaultMessage: PropTypes.string, 35 | description: PropTypes.string, 36 | id: PropTypes.string 37 | }).isRequired, 38 | imgSrc: PropTypes.string.isRequired, 39 | intl: intlShape.isRequired, 40 | isSelected: PropTypes.bool.isRequired, 41 | onMouseDown: PropTypes.func.isRequired 42 | }; 43 | 44 | export default injectIntl(ToolSelectComponent); 45 | -------------------------------------------------------------------------------- /src/containers/fill-color-indicator.jsx: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux'; 2 | import {defineMessages} from 'react-intl'; 3 | 4 | import {changeColorIndex} from '../reducers/color-index'; 5 | import {changeFillColor, changeFillColor2} from '../reducers/fill-style'; 6 | import {changeGradientType} from '../reducers/fill-mode-gradient-type'; 7 | import {openFillColor, closeFillColor} from '../reducers/modals'; 8 | import {getSelectedLeafItems} from '../helper/selection'; 9 | import {setSelectedItems} from '../reducers/selected-items'; 10 | import Modes, {GradientToolsModes} from '../lib/modes'; 11 | import {isBitmap} from '../lib/format'; 12 | 13 | import makeColorIndicator from './color-indicator.jsx'; 14 | 15 | const messages = defineMessages({ 16 | label: { 17 | id: 'paint.paintEditor.fill', 18 | description: 'Label for the color picker for the fill color', 19 | defaultMessage: 'Fill' 20 | } 21 | }); 22 | 23 | const FillColorIndicator = makeColorIndicator(messages.label, false); 24 | 25 | const mapStateToProps = state => ({ 26 | colorIndex: state.scratchPaint.fillMode.colorIndex, 27 | disabled: state.scratchPaint.mode === Modes.LINE, 28 | color: state.scratchPaint.color.fillColor.primary, 29 | color2: state.scratchPaint.color.fillColor.secondary, 30 | colorModalVisible: state.scratchPaint.modals.fillColor, 31 | fillBitmapShapes: state.scratchPaint.fillBitmapShapes, 32 | format: state.scratchPaint.format, 33 | gradientType: state.scratchPaint.color.fillColor.gradientType, 34 | isEyeDropping: state.scratchPaint.color.eyeDropper.active, 35 | mode: state.scratchPaint.mode, 36 | shouldShowGradientTools: state.scratchPaint.mode in GradientToolsModes, 37 | textEditTarget: state.scratchPaint.textEditTarget 38 | }); 39 | 40 | const mapDispatchToProps = dispatch => ({ 41 | onChangeColorIndex: index => { 42 | dispatch(changeColorIndex(index)); 43 | }, 44 | onChangeColor: (fillColor, index) => { 45 | if (index === 0) { 46 | dispatch(changeFillColor(fillColor)); 47 | } else if (index === 1) { 48 | dispatch(changeFillColor2(fillColor)); 49 | } 50 | }, 51 | onOpenColor: () => { 52 | dispatch(openFillColor()); 53 | }, 54 | onCloseColor: () => { 55 | dispatch(closeFillColor()); 56 | }, 57 | onChangeGradientType: gradientType => { 58 | dispatch(changeGradientType(gradientType)); 59 | }, 60 | setSelectedItems: format => { 61 | dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); 62 | } 63 | }); 64 | 65 | export default connect( 66 | mapStateToProps, 67 | mapDispatchToProps 68 | )(FillColorIndicator); 69 | -------------------------------------------------------------------------------- /src/containers/paper-canvas.css: -------------------------------------------------------------------------------- 1 | .paper-canvas { 2 | top: 1px; /* leave room for the border */ 3 | left: 1px; 4 | width: calc(100% - 2px); 5 | height: calc(100% - 2px); 6 | margin: auto; 7 | position: absolute; 8 | background-color: #D9E3F2; 9 | } 10 | -------------------------------------------------------------------------------- /src/css/colors.css: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | $ui-pane-border: #D9D9D9; 6 | $ui-pane-gray: #F9F9F9; 7 | $ui-background-blue: #e8edf1; 8 | 9 | $text-primary: #575e75; 10 | 11 | $looks-secondary: #855CD6; 12 | $looks-transparent: hsla(260, 60%, 60%, 0.35); /* 35% transparent version of looks-secondary */ 13 | 14 | $red-primary: #FF661A; 15 | $red-tertiary: #E64D00; 16 | 17 | $sound-primary: #CF63CF; 18 | $sound-tertiary: #A63FA6; 19 | 20 | $control-primary: #FFAB19; 21 | 22 | $data-primary: #FF8C1A; 23 | 24 | $form-border: #E9EEF2; 25 | -------------------------------------------------------------------------------- /src/css/units.css: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | /* ACTUALLY, THIS IS EDITED ;) 6 | THIS WAS CHANGED ON 10/25/2017 BY @mewtaylor TO ADD A VARIABLE FOR THE SMALLEST 7 | GRID UNITS. 8 | 9 | ALSO EDITED ON 11/13/2017 TO ADD IN CONTANTS FOR LAYOUT FROM `layout-contents.js`*/ 10 | 11 | $space: 0.5rem; 12 | $grid-unit: .25rem; 13 | 14 | $sprites-per-row: 5; 15 | 16 | $menu-bar-height: 3rem; 17 | $sprite-info-height: 6rem; 18 | $stage-menu-height: 2.75rem; 19 | 20 | $library-header-height: 4.375rem; 21 | 22 | $form-radius: calc($space / 2); 23 | 24 | /* layout contants from `layout-constants.js`, minus 1px */ 25 | $full-size: 1095px; 26 | $full-size-paint: 1256px; 27 | -------------------------------------------------------------------------------- /src/helper/compound-path.js: -------------------------------------------------------------------------------- 1 | const isCompoundPath = function (item) { 2 | return item && item.className === 'CompoundPath'; 3 | }; 4 | 5 | const isCompoundPathChild = function (item) { 6 | if (item.parent) { 7 | return item.parent.className === 'CompoundPath'; 8 | } 9 | return false; 10 | }; 11 | 12 | 13 | const getItemsCompoundPath = function (item) { 14 | const itemParent = item.parent; 15 | 16 | if (isCompoundPath(itemParent)) { 17 | return itemParent; 18 | } 19 | return null; 20 | 21 | }; 22 | 23 | export { 24 | isCompoundPath, 25 | isCompoundPathChild, 26 | getItemsCompoundPath 27 | }; 28 | -------------------------------------------------------------------------------- /src/helper/hover.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import {isBoundsItem, getRootItem} from './item'; 3 | import {hoverBounds, hoverItem} from './guides'; 4 | import {isGroupChild} from './group'; 5 | import {sortItemsByZIndex} from './math'; 6 | 7 | /** 8 | * @param {!MouseEvent} event mouse event 9 | * @param {?object} hitOptions hit options to use 10 | * @param {?boolean} subselect Whether items within groups can be hovered. If false, the 11 | * entire group should be hovered. 12 | * @return {paper.Item} the hovered item or null if there is none 13 | */ 14 | const getHoveredItem = function (event, hitOptions, subselect) { 15 | const oldMatch = hitOptions.match; 16 | hitOptions.match = hitResult => { 17 | if (hitResult.item.data && hitResult.item.data.noHover) return false; 18 | return oldMatch ? oldMatch(hitResult) : true; 19 | }; 20 | const hitResults = paper.project.hitTestAll(event.point, hitOptions); 21 | if (hitResults.length === 0) { 22 | return null; 23 | } 24 | 25 | // Get highest z-index result 26 | let hitResult; 27 | for (const result of hitResults) { 28 | if (!hitResult || sortItemsByZIndex(hitResult.item, result.item) < 0) { 29 | hitResult = result; 30 | } 31 | } 32 | const item = hitResult.item; 33 | // If the hovered item is already selected, then there should be no hovered item. 34 | if (!item || item.selected) { 35 | return null; 36 | } 37 | 38 | let hoverGuide; 39 | if (isBoundsItem(item)) { 40 | hoverGuide = hoverBounds(item); 41 | } else if (!subselect && isGroupChild(item)) { 42 | hoverGuide = hoverBounds(getRootItem(item)); 43 | } else { 44 | hoverGuide = hoverItem(item); 45 | } 46 | hoverGuide.data.hitResult = hitResult; 47 | 48 | return hoverGuide; 49 | }; 50 | 51 | export { 52 | getHoveredItem 53 | }; 54 | -------------------------------------------------------------------------------- /src/helper/item.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | 3 | const getRootItem = function (item) { 4 | if (item.parent.className === 'Layer') { 5 | return item; 6 | } 7 | return getRootItem(item.parent); 8 | }; 9 | 10 | const isBoundsItem = function (item) { 11 | if (item.className === 'PointText' || 12 | item.className === 'Shape' || 13 | item.className === 'PlacedSymbol' || 14 | item.className === 'Raster') { 15 | return true; 16 | } 17 | return false; 18 | }; 19 | 20 | 21 | const isPathItem = function (item) { 22 | return item.className === 'Path'; 23 | }; 24 | 25 | 26 | const isCompoundPathItem = function (item) { 27 | return item.className === 'CompoundPath'; 28 | }; 29 | 30 | 31 | const isGroupItem = function (item) { 32 | return item && item.className && item.className === 'Group'; 33 | }; 34 | 35 | 36 | const isPointTextItem = function (item) { 37 | return item.className === 'PointText'; 38 | }; 39 | 40 | 41 | const isPGTextItem = function (item) { 42 | return getRootItem(item).data.isPGTextItem; 43 | }; 44 | 45 | const setPivot = function (item, point) { 46 | if (isBoundsItem(item)) { 47 | item.pivot = item.globalToLocal(point); 48 | } else { 49 | item.pivot = point; 50 | } 51 | }; 52 | 53 | 54 | const getPositionInView = function (item) { 55 | const itemPos = new paper.Point(); 56 | itemPos.x = item.position.x - paper.view.bounds.x; 57 | itemPos.y = item.position.y - paper.view.bounds.y; 58 | return itemPos; 59 | }; 60 | 61 | 62 | const setPositionInView = function (item, pos) { 63 | item.position.x = paper.view.bounds.x + pos.x; 64 | item.position.y = paper.view.bounds.y + pos.y; 65 | }; 66 | 67 | export { 68 | isBoundsItem, 69 | isPathItem, 70 | isCompoundPathItem, 71 | isGroupItem, 72 | isPointTextItem, 73 | isPGTextItem, 74 | setPivot, 75 | getPositionInView, 76 | setPositionInView, 77 | getRootItem 78 | }; 79 | -------------------------------------------------------------------------------- /src/helper/order.js: -------------------------------------------------------------------------------- 1 | import {getSelectedRootItems} from './selection'; 2 | 3 | const bringToFront = function (onUpdateImage) { 4 | const items = getSelectedRootItems(); 5 | for (const item of items) { 6 | item.bringToFront(); 7 | } 8 | onUpdateImage(); 9 | }; 10 | 11 | const sendToBack = function (onUpdateImage) { 12 | const items = getSelectedRootItems(); 13 | for (let i = items.length - 1; i >= 0; i--) { 14 | items[i].sendToBack(); 15 | } 16 | onUpdateImage(); 17 | }; 18 | 19 | const bringForward = function (onUpdateImage) { 20 | const items = getSelectedRootItems(); 21 | // Already at front 22 | if (items.length === 0 || !items[items.length - 1].nextSibling) { 23 | return; 24 | } 25 | 26 | const nextSibling = items[items.length - 1].nextSibling; 27 | for (let i = items.length - 1; i >= 0; i--) { 28 | items[i].insertAbove(nextSibling); 29 | } 30 | onUpdateImage(); 31 | }; 32 | 33 | const sendBackward = function (onUpdateImage) { 34 | const items = getSelectedRootItems(); 35 | // Already at front 36 | if (items.length === 0 || !items[0].previousSibling) { 37 | return; 38 | } 39 | 40 | const previousSibling = items[0].previousSibling; 41 | for (const item of items) { 42 | item.insertBelow(previousSibling); 43 | } 44 | onUpdateImage(); 45 | }; 46 | 47 | const shouldShowSendBackward = function () { 48 | const items = getSelectedRootItems(); 49 | if (items.length === 0 || !items[0].previousSibling) { 50 | return false; 51 | } 52 | return true; 53 | }; 54 | 55 | const shouldShowBringForward = function () { 56 | const items = getSelectedRootItems(); 57 | if (items.length === 0 || !items[items.length - 1].nextSibling) { 58 | return false; 59 | } 60 | return true; 61 | }; 62 | 63 | export { 64 | bringToFront, 65 | sendToBack, 66 | bringForward, 67 | sendBackward, 68 | shouldShowBringForward, 69 | shouldShowSendBackward 70 | }; 71 | -------------------------------------------------------------------------------- /src/helper/selection-tools/rotate-tool.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | 3 | /** 4 | * Tool to handle rotation when dragging the rotation handle in the bounding box tool. 5 | */ 6 | class RotateTool { 7 | /** 8 | * @param {!function} onUpdateImage A callback to call when the image visibly changes 9 | */ 10 | constructor (onUpdateImage) { 11 | this.rotItems = []; 12 | this.rotGroupPivot = null; 13 | this.prevRot = 90; 14 | this.onUpdateImage = onUpdateImage; 15 | } 16 | 17 | /** 18 | * @param {!paper.HitResult} hitResult Data about the location of the mouse click 19 | * @param {!object} boundsPath Where the boundaries of the hit item are 20 | * @param {!Array.} selectedItems Set of selected paper.Items 21 | */ 22 | onMouseDown (hitResult, boundsPath, selectedItems) { 23 | this.rotGroupPivot = boundsPath.bounds.center; 24 | for (const item of selectedItems) { 25 | // Rotate only root items 26 | if (item.parent instanceof paper.Layer) { 27 | this.rotItems.push(item); 28 | } 29 | } 30 | this.prevRot = 90; 31 | } 32 | onMouseDrag (event) { 33 | let rotAngle = (event.point.subtract(this.rotGroupPivot)).angle; 34 | if (event.modifiers.shift) { 35 | rotAngle = Math.round(rotAngle / 45) * 45; 36 | } 37 | 38 | for (let i = 0; i < this.rotItems.length; i++) { 39 | const item = this.rotItems[i]; 40 | 41 | item.rotate(rotAngle - this.prevRot, this.rotGroupPivot); 42 | } 43 | 44 | this.prevRot = rotAngle; 45 | } 46 | onMouseUp (event) { 47 | if (event.event.button > 0) return; // only first mouse button 48 | 49 | this.rotItems.length = 0; 50 | this.rotGroupPivot = null; 51 | this.prevRot = 90; 52 | 53 | this.onUpdateImage(); 54 | } 55 | } 56 | 57 | export default RotateTool; 58 | -------------------------------------------------------------------------------- /src/helper/snapping.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import {getItems} from './selection'; 3 | 4 | /** 5 | * @param {paper.Point} point1 point 1 6 | * @param {paper.Point} point2 point 2 7 | * @param {number} tolerance Distance allowed between points that are "touching" 8 | * @return {boolean} true if points are within the tolerance distance. 9 | */ 10 | const touching = function (point1, point2, tolerance) { 11 | return point1.getDistance(point2, true) < Math.pow(tolerance / paper.view.zoom, 2); 12 | }; 13 | 14 | /** 15 | * @param {!paper.Point} point Point to check line endpoint hits against 16 | * @param {!number} tolerance Distance within which it counts as a hit 17 | * @param {?paper.Path} excludePath Path to exclude from hit test, if any. For instance, you 18 | * are drawing a line and don't want it to snap to its own start point. 19 | * @return {object} data about the end point of an unclosed path, if any such point is within the 20 | * tolerance distance of the given point, or null if none exists. 21 | */ 22 | const endPointHit = function (point, tolerance, excludePath) { 23 | const lines = getItems({ 24 | class: paper.Path 25 | }); 26 | // Prefer more recent lines 27 | for (let i = lines.length - 1; i >= 0; i--) { 28 | if (lines[i].closed) { 29 | continue; 30 | } 31 | if (!(lines[i].parent instanceof paper.Layer)) { 32 | // Don't connect to lines inside of groups 33 | continue; 34 | } 35 | if (excludePath && lines[i] === excludePath) { 36 | continue; 37 | } 38 | if (lines[i].firstSegment && touching(lines[i].firstSegment.point, point, tolerance)) { 39 | return { 40 | path: lines[i], 41 | segment: lines[i].firstSegment, 42 | isFirst: true 43 | }; 44 | } 45 | if (lines[i].lastSegment && touching(lines[i].lastSegment.point, point, tolerance)) { 46 | return { 47 | path: lines[i], 48 | segment: lines[i].lastSegment, 49 | isFirst: false 50 | }; 51 | } 52 | } 53 | return null; 54 | }; 55 | 56 | export { 57 | endPointHit, 58 | touching 59 | }; 60 | -------------------------------------------------------------------------------- /src/helper/tools/rounded-rect-tool.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import log from '../../log/log'; 3 | 4 | /** 5 | * Tool for drawing rounded rectangles 6 | */ 7 | class RoundedRectTool extends paper.Tool { 8 | /** 9 | * @param {function} setHoveredItem Callback to set the hovered item 10 | * @param {function} clearHoveredItem Callback to clear the hovered item 11 | * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state 12 | * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state 13 | * @param {!function} onUpdateImage A callback to call when the image visibly changes 14 | */ 15 | constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateImage) { 16 | super(); 17 | this.setHoveredItem = setHoveredItem; 18 | this.clearHoveredItem = clearHoveredItem; 19 | this.setSelectedItems = setSelectedItems; 20 | this.clearSelectedItems = clearSelectedItems; 21 | this.onUpdateImage = onUpdateImage; 22 | this.prevHoveredItemId = null; 23 | 24 | // We have to set these functions instead of just declaring them because 25 | // paper.js tools hook up the listeners in the setter functions. 26 | this.onMouseDown = this.handleMouseDown; 27 | this.onMouseMove = this.handleMouseMove; 28 | this.onMouseDrag = this.handleMouseDrag; 29 | this.onMouseUp = this.handleMouseUp; 30 | } 31 | /** 32 | * To be called when the hovered item changes. When the select tool hovers over a 33 | * new item, it compares against this to see if a hover item change event needs to 34 | * be fired. 35 | * @param {paper.Item} prevHoveredItemId ID of the highlight item that indicates the mouse is 36 | * over a given item currently 37 | */ 38 | setPrevHoveredItemId (prevHoveredItemId) { 39 | this.prevHoveredItemId = prevHoveredItemId; 40 | } 41 | handleMouseDown () { 42 | log.warn('Rounded Rectangle tool not yet implemented'); 43 | } 44 | handleMouseMove () { 45 | } 46 | handleMouseDrag () { 47 | } 48 | handleMouseUp () { 49 | } 50 | deactivateTool () { 51 | } 52 | } 53 | 54 | export default RoundedRectTool; 55 | -------------------------------------------------------------------------------- /src/hocs/selection-hoc.jsx: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import React from 'react'; 5 | import {connect} from 'react-redux'; 6 | import bindAll from 'lodash.bindall'; 7 | 8 | const SelectionHOC = function (WrappedComponent) { 9 | class SelectionComponent extends React.Component { 10 | constructor (props) { 11 | super(props); 12 | bindAll(this, [ 13 | 'removeItemById' 14 | ]); 15 | } 16 | componentDidUpdate (prevProps) { 17 | // Hovered item has changed 18 | if ((this.props.hoveredItemId && this.props.hoveredItemId !== prevProps.hoveredItemId) || 19 | (!this.props.hoveredItemId && prevProps.hoveredItemId)) { 20 | // Remove the old hover item if any 21 | this.removeItemById(prevProps.hoveredItemId); 22 | } 23 | } 24 | removeItemById (itemId) { 25 | if (itemId) { 26 | const match = paper.project.getItem({ 27 | match: item => (item.id === itemId) 28 | }); 29 | if (match) { 30 | match.remove(); 31 | } 32 | } 33 | } 34 | render () { 35 | const { 36 | hoveredItemId, // eslint-disable-line no-unused-vars 37 | ...props 38 | } = this.props; 39 | return ( 40 | 41 | ); 42 | } 43 | } 44 | SelectionComponent.propTypes = { 45 | hoveredItemId: PropTypes.number 46 | }; 47 | 48 | const mapStateToProps = state => ({ 49 | hoveredItemId: state.scratchPaint.hoveredItemId 50 | }); 51 | return connect( 52 | mapStateToProps 53 | )(SelectionComponent); 54 | }; 55 | 56 | export default SelectionHOC; 57 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import PaintEditor from './containers/paint-editor.jsx'; 2 | import ScratchPaintReducer from './reducers/scratch-paint-reducer'; 3 | 4 | export { 5 | PaintEditor as default, 6 | ScratchPaintReducer 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/color-style-proptype.js: -------------------------------------------------------------------------------- 1 | import {PropTypes} from 'prop-types'; 2 | 3 | import GradientTypes from './gradient-types'; 4 | 5 | export default PropTypes.shape({ 6 | primary: PropTypes.string, 7 | secondary: PropTypes.string, 8 | gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/cursors.js: -------------------------------------------------------------------------------- 1 | const Cursors = { 2 | DEFAULT: 'default', 3 | GRAB: 'grab', 4 | GRABBING: 'grabbing', 5 | NONE: 'none', 6 | RESIZE_EW: 'ew-resize', 7 | RESIZE_NS: 'ns-resize', 8 | RESIZE_NESW: 'nesw-resize', 9 | RESIZE_NWSE: 'nwse-resize' 10 | }; 11 | 12 | export default Cursors; 13 | -------------------------------------------------------------------------------- /src/lib/fonts.js: -------------------------------------------------------------------------------- 1 | const Fonts = { 2 | SANS_SERIF: 'Sans Serif', 3 | SERIF: 'Serif', 4 | HANDWRITING: 'Handwriting', 5 | MARKER: 'Marker', 6 | CURLY: 'Curly', 7 | PIXEL: 'Pixel', 8 | CHINESE: '"Microsoft YaHei", "微软雅黑", STXihei, "华文细黑"', 9 | JAPANESE: '"ヒラギノ角ゴ Pro W3", "Hiragino Kaku Gothic Pro", Osaka, "メイリオ", Meiryo, "MS Pゴシック", "MS PGothic"', 10 | KOREAN: 'Malgun Gothic' 11 | }; 12 | 13 | export default Fonts; 14 | -------------------------------------------------------------------------------- /src/lib/format.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'keymirror'; 2 | 3 | const Formats = keyMirror({ 4 | BITMAP: null, 5 | VECTOR: null, 6 | // Format changes which should not trigger conversions, for instance undo 7 | BITMAP_SKIP_CONVERT: null, 8 | VECTOR_SKIP_CONVERT: null 9 | }); 10 | 11 | const isVector = function (format) { 12 | return format === Formats.VECTOR || format === Formats.VECTOR_SKIP_CONVERT; 13 | }; 14 | 15 | const isBitmap = function (format) { 16 | return format === Formats.BITMAP || format === Formats.BITMAP_SKIP_CONVERT; 17 | }; 18 | 19 | export { 20 | Formats as default, 21 | isVector, 22 | isBitmap 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/gradient-types.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'keymirror'; 2 | 3 | const GradientTypes = keyMirror({ 4 | SOLID: null, 5 | HORIZONTAL: null, 6 | VERTICAL: null, 7 | RADIAL: null 8 | }); 9 | export default GradientTypes; 10 | -------------------------------------------------------------------------------- /src/lib/hide-label.js: -------------------------------------------------------------------------------- 1 | const localeTooBig = [ 2 | 'ab', 3 | 'ca', 4 | 'cy', 5 | 'de', 6 | 'et', 7 | 'el', 8 | 'ga', 9 | 'gd', 10 | 'gl', 11 | 'mi', 12 | 'nl', 13 | 'ja', 14 | 'ja-Hira', 15 | 'nb', 16 | 'nn', 17 | 'rap', 18 | 'th', 19 | 'sr', 20 | 'sk', 21 | 'sl', 22 | 'fi', 23 | 'sv', 24 | 'sw', 25 | 'vi', 26 | 'tr', 27 | 'uk' 28 | ]; 29 | 30 | const hideLabel = locale => localeTooBig.includes(locale); 31 | 32 | export { 33 | hideLabel 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/layout-constants.js: -------------------------------------------------------------------------------- 1 | export default { 2 | fullSizeEditorMinWidth: 1274 3 | }; 4 | -------------------------------------------------------------------------------- /src/lib/messages.js: -------------------------------------------------------------------------------- 1 | import {defineMessages} from 'react-intl'; 2 | 3 | const messages = defineMessages({ 4 | brush: { 5 | defaultMessage: 'Brush', 6 | description: 'Label for the brush tool', 7 | id: 'paint.brushMode.brush' 8 | }, 9 | eraser: { 10 | defaultMessage: 'Eraser', 11 | description: 'Label for the eraser tool', 12 | id: 'paint.eraserMode.eraser' 13 | }, 14 | fill: { 15 | defaultMessage: 'Fill', 16 | description: 'Label for the fill tool', 17 | id: 'paint.fillMode.fill' 18 | }, 19 | line: { 20 | defaultMessage: 'Line', 21 | description: 'Label for the line tool', 22 | id: 'paint.lineMode.line' 23 | }, 24 | oval: { 25 | defaultMessage: 'Circle', 26 | description: 'Label for the oval-drawing tool', 27 | id: 'paint.ovalMode.oval' 28 | }, 29 | rect: { 30 | defaultMessage: 'Rectangle', 31 | description: 'Label for the rectangle tool', 32 | id: 'paint.rectMode.rect' 33 | }, 34 | reshape: { 35 | defaultMessage: 'Reshape', 36 | description: 'Label for the reshape tool, which allows changing the points in the lines of the vectors', 37 | id: 'paint.reshapeMode.reshape' 38 | }, 39 | roundedRect: { 40 | defaultMessage: 'Rounded Rectangle', 41 | description: 'Label for the rounded rectangle tool', 42 | id: 'paint.roundedRectMode.roundedRect' 43 | }, 44 | select: { 45 | defaultMessage: 'Select', 46 | description: 'Label for the select tool, which allows selecting, moving, and resizing shapes', 47 | id: 'paint.selectMode.select' 48 | }, 49 | text: { 50 | defaultMessage: 'Text', 51 | description: 'Label for the text tool', 52 | id: 'paint.textMode.text' 53 | } 54 | }); 55 | 56 | export default messages; 57 | -------------------------------------------------------------------------------- /src/lib/modes.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'keymirror'; 2 | 3 | const vectorModesObj = { 4 | BRUSH: null, 5 | ERASER: null, 6 | LINE: null, 7 | FILL: null, 8 | SELECT: null, 9 | RESHAPE: null, 10 | OVAL: null, 11 | RECT: null, 12 | ROUNDED_RECT: null, 13 | TEXT: null 14 | }; 15 | const bitmapModesObj = { 16 | BIT_BRUSH: null, 17 | BIT_LINE: null, 18 | BIT_OVAL: null, 19 | BIT_RECT: null, 20 | BIT_TEXT: null, 21 | BIT_FILL: null, 22 | BIT_ERASER: null, 23 | BIT_SELECT: null 24 | }; 25 | const VectorModes = keyMirror(vectorModesObj); 26 | const BitmapModes = keyMirror(bitmapModesObj); 27 | const Modes = keyMirror({...vectorModesObj, ...bitmapModesObj}); 28 | 29 | const GradientToolsModes = keyMirror({ 30 | FILL: null, 31 | SELECT: null, 32 | RESHAPE: null, 33 | OVAL: null, 34 | RECT: null, 35 | LINE: null, 36 | 37 | BIT_OVAL: null, 38 | BIT_RECT: null, 39 | BIT_SELECT: null, 40 | BIT_FILL: null 41 | }); 42 | 43 | export { 44 | Modes as default, 45 | VectorModes, 46 | BitmapModes, 47 | GradientToolsModes 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/touch-utils.js: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT 2 | @todo This file is copied from GUI and should be pulled out into a shared library. 3 | See https://github.com/LLK/scratch-paint/issues/13 */ 4 | 5 | const getEventXY = e => { 6 | if (e.touches && e.touches[0]) { 7 | return {x: e.touches[0].clientX, y: e.touches[0].clientY}; 8 | } else if (e.changedTouches && e.changedTouches[0]) { 9 | return {x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY}; 10 | } 11 | return {x: e.clientX, y: e.clientY}; 12 | }; 13 | 14 | export { 15 | getEventXY 16 | }; 17 | -------------------------------------------------------------------------------- /src/log/log.js: -------------------------------------------------------------------------------- 1 | import minilog from 'minilog'; 2 | minilog.enable(); 3 | 4 | export default minilog('scratch-paint'); 5 | -------------------------------------------------------------------------------- /src/playground/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlWebpackPlugin.options.title %> 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/playground/playground.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | margin: 0px; 5 | } 6 | 7 | body, html, .wrapper { 8 | height: 100% 9 | } 10 | 11 | .playgroundContainer{ 12 | height: 90%; 13 | width: 90%; 14 | margin: auto; 15 | } 16 | 17 | #fileInput { 18 | display: none; 19 | } 20 | 21 | .playgroundButton { 22 | margin: 4px; 23 | } 24 | -------------------------------------------------------------------------------- /src/playground/reducers/combine-reducers.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import intlReducer from './intl'; 3 | import {ScratchPaintReducer} from '../..'; 4 | 5 | export default combineReducers({ 6 | intl: intlReducer, 7 | scratchPaint: ScratchPaintReducer 8 | }); 9 | -------------------------------------------------------------------------------- /src/playground/reducers/intl.js: -------------------------------------------------------------------------------- 1 | import {addLocaleData} from 'react-intl'; 2 | import {updateIntl as superUpdateIntl} from 'react-intl-redux'; 3 | import {IntlProvider, intlReducer} from 'react-intl-redux'; 4 | 5 | import localeData from 'scratch-l10n'; 6 | import paintMessages from 'scratch-l10n/locales/paint-editor-msgs'; 7 | 8 | Object.keys(localeData).forEach(locale => { 9 | // TODO: will need to handle locales not in the default intl - see www/custom-locales 10 | addLocaleData(localeData[locale].localeData); 11 | }); 12 | 13 | const intlInitialState = { 14 | intl: { 15 | defaultLocale: 'en', 16 | locale: 'en', 17 | messages: paintMessages.en.messages 18 | } 19 | }; 20 | 21 | const updateIntl = locale => superUpdateIntl({ 22 | locale: locale, 23 | messages: paintMessages[locale].messages || paintMessages.en.messages 24 | }); 25 | 26 | export { 27 | intlReducer as default, 28 | IntlProvider, 29 | intlInitialState, 30 | updateIntl 31 | }; 32 | -------------------------------------------------------------------------------- /src/reducers/bit-brush-size.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | import {CHANGE_SELECTED_ITEMS} from './selected-items'; 3 | import {getColorsFromSelection} from '../helper/style-path'; 4 | 5 | // Bit brush size affects bit brush width, circle/rectangle outline drawing width, and line width 6 | // in the bitmap paint editor. 7 | const CHANGE_BIT_BRUSH_SIZE = 'scratch-paint/brush-mode/CHANGE_BIT_BRUSH_SIZE'; 8 | const initialState = 10; 9 | 10 | const reducer = function (state, action) { 11 | if (typeof state === 'undefined') state = initialState; 12 | switch (action.type) { 13 | case CHANGE_BIT_BRUSH_SIZE: 14 | if (isNaN(action.brushSize)) { 15 | log.warn(`Invalid brush size: ${action.brushSize}`); 16 | return state; 17 | } 18 | return Math.max(1, action.brushSize); 19 | case CHANGE_SELECTED_ITEMS: 20 | { 21 | // Don't change state if no selection 22 | if (!action.selectedItems || !action.selectedItems.length) { 23 | return state; 24 | } 25 | // Vector mode doesn't have bit width 26 | if (!action.bitmapMode) { 27 | return state; 28 | } 29 | const colorState = getColorsFromSelection(action.selectedItems, action.bitmapMode); 30 | if (colorState.thickness) return colorState.thickness; 31 | return state; 32 | } 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | // Action creators ================================== 39 | const changeBitBrushSize = function (brushSize) { 40 | return { 41 | type: CHANGE_BIT_BRUSH_SIZE, 42 | brushSize: brushSize 43 | }; 44 | }; 45 | 46 | export { 47 | reducer as default, 48 | changeBitBrushSize 49 | }; 50 | -------------------------------------------------------------------------------- /src/reducers/bit-eraser-size.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const CHANGE_BIT_ERASER_SIZE = 'scratch-paint/eraser-mode/CHANGE_BIT_ERASER_SIZE'; 4 | const initialState = 40; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_BIT_ERASER_SIZE: 10 | if (isNaN(action.eraserSize)) { 11 | log.warn(`Invalid eraser size: ${action.eraserSize}`); 12 | return state; 13 | } 14 | return Math.max(1, action.eraserSize); 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | // Action creators ================================== 21 | const changeBitEraserSize = function (eraserSize) { 22 | return { 23 | type: CHANGE_BIT_ERASER_SIZE, 24 | eraserSize: eraserSize 25 | }; 26 | }; 27 | 28 | export { 29 | reducer as default, 30 | changeBitEraserSize 31 | }; 32 | -------------------------------------------------------------------------------- /src/reducers/brush-mode.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const CHANGE_BRUSH_SIZE = 'scratch-paint/brush-mode/CHANGE_BRUSH_SIZE'; 4 | const initialState = {brushSize: 10}; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_BRUSH_SIZE: 10 | if (isNaN(action.brushSize)) { 11 | log.warn(`Invalid brush size: ${action.brushSize}`); 12 | return state; 13 | } 14 | return {brushSize: Math.max(1, action.brushSize)}; 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | // Action creators ================================== 21 | const changeBrushSize = function (brushSize) { 22 | return { 23 | type: CHANGE_BRUSH_SIZE, 24 | brushSize: brushSize 25 | }; 26 | }; 27 | 28 | export { 29 | reducer as default, 30 | changeBrushSize 31 | }; 32 | -------------------------------------------------------------------------------- /src/reducers/clipboard.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const SET = 'scratch-paint/clipboard/SET'; 4 | const INCREMENT_PASTE_OFFSET = 'scratch-paint/clipboard/INCREMENT_PASTE_OFFSET'; 5 | const CLEAR_PASTE_OFFSET = 'scratch-paint/clipboard/CLEAR_PASTE_OFFSET'; 6 | const initialState = { 7 | items: [], 8 | pasteOffset: 0 9 | }; 10 | 11 | const reducer = function (state, action) { 12 | if (typeof state === 'undefined') state = initialState; 13 | switch (action.type) { 14 | case SET: 15 | if (!action.clipboardItems || !(action.clipboardItems instanceof Array) || action.clipboardItems.length === 0) { 16 | log.warn(`Invalid clipboard item format`); 17 | return state; 18 | } 19 | return { 20 | items: action.clipboardItems, 21 | pasteOffset: 1 22 | }; 23 | case INCREMENT_PASTE_OFFSET: 24 | return { 25 | items: state.items, 26 | pasteOffset: state.pasteOffset + 1 27 | }; 28 | case CLEAR_PASTE_OFFSET: 29 | return { 30 | items: state.items, 31 | pasteOffset: 0 32 | }; 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | // Action creators ================================== 39 | const setClipboardItems = function (clipboardItems) { 40 | return { 41 | type: SET, 42 | clipboardItems: clipboardItems 43 | }; 44 | }; 45 | 46 | const incrementPasteOffset = function () { 47 | return { 48 | type: INCREMENT_PASTE_OFFSET 49 | }; 50 | }; 51 | 52 | const clearPasteOffset = function () { 53 | return { 54 | type: CLEAR_PASTE_OFFSET 55 | }; 56 | }; 57 | 58 | export { 59 | reducer as default, 60 | setClipboardItems, 61 | incrementPasteOffset, 62 | clearPasteOffset 63 | }; 64 | -------------------------------------------------------------------------------- /src/reducers/color-index.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | import {CHANGE_FILL_GRADIENT_TYPE} from './fill-style'; 3 | import GradientTypes from '../lib/gradient-types'; 4 | 5 | const CHANGE_COLOR_INDEX = 'scratch-paint/color-index/CHANGE_COLOR_INDEX'; 6 | const initialState = 0; 7 | 8 | const reducer = function (state, action) { 9 | if (typeof state === 'undefined') state = initialState; 10 | switch (action.type) { 11 | case CHANGE_COLOR_INDEX: 12 | if (action.index !== 1 && action.index !== 0) { 13 | log.warn(`Invalid color index: ${action.index}`); 14 | return state; 15 | } 16 | return action.index; 17 | case CHANGE_FILL_GRADIENT_TYPE: 18 | if (action.gradientType === GradientTypes.SOLID) return 0; 19 | /* falls through */ 20 | default: 21 | return state; 22 | } 23 | }; 24 | 25 | // Action creators ================================== 26 | const changeColorIndex = function (index) { 27 | return { 28 | type: CHANGE_COLOR_INDEX, 29 | index: index 30 | }; 31 | }; 32 | 33 | export { 34 | reducer as default, 35 | changeColorIndex 36 | }; 37 | -------------------------------------------------------------------------------- /src/reducers/color.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import eyeDropperReducer from './eye-dropper'; 3 | import fillColorReducer from './fill-style'; 4 | import strokeColorReducer from './stroke-style'; 5 | import strokeWidthReducer from './stroke-width'; 6 | 7 | export default combineReducers({ 8 | eyeDropper: eyeDropperReducer, 9 | fillColor: fillColorReducer, 10 | strokeColor: strokeColorReducer, 11 | strokeWidth: strokeWidthReducer 12 | }); 13 | -------------------------------------------------------------------------------- /src/reducers/cursor.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | import Cursors from '../lib/cursors'; 4 | import {ACTIVATE_EYE_DROPPER, DEACTIVATE_EYE_DROPPER} from './eye-dropper'; 5 | 6 | const CHANGE_CURSOR = 'scratch-paint/cursor/CHANGE_CURSOR'; 7 | const initialState = Cursors.DEFAULT; 8 | 9 | const reducer = function (state, action) { 10 | if (typeof state === 'undefined') state = initialState; 11 | switch (action.type) { 12 | case CHANGE_CURSOR: 13 | if (typeof action.cursorString === 'undefined') { 14 | log.warn(`Cursor should not be set to undefined. Use 'default'.`); 15 | return state; 16 | } else if (!Object.values(Cursors).includes(action.cursorString)) { 17 | log.warn(`Cursor should be a valid cursor string. Got: ${action.cursorString}`); 18 | } 19 | return action.cursorString; 20 | case ACTIVATE_EYE_DROPPER: 21 | return Cursors.NONE; 22 | case DEACTIVATE_EYE_DROPPER: 23 | return Cursors.DEFAULT; 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | // Action creators ================================== 30 | /** 31 | * Set the mouse cursor state to the given string 32 | * @param {string} cursorString The CSS cursor string. 33 | * @return {object} Redux action to change the cursor. 34 | */ 35 | const setCursor = function (cursorString) { 36 | return { 37 | type: CHANGE_CURSOR, 38 | cursorString: cursorString 39 | }; 40 | }; 41 | 42 | export { 43 | reducer as default, 44 | setCursor 45 | }; 46 | -------------------------------------------------------------------------------- /src/reducers/eraser-mode.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const CHANGE_ERASER_SIZE = 'scratch-paint/eraser-mode/CHANGE_ERASER_SIZE'; 4 | const initialState = {brushSize: 40}; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_ERASER_SIZE: 10 | if (isNaN(action.brushSize)) { 11 | log.warn(`Invalid brush size: ${action.brushSize}`); 12 | return state; 13 | } 14 | return {brushSize: Math.max(1, action.brushSize)}; 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | // Action creators ================================== 21 | const changeBrushSize = function (brushSize) { 22 | return { 23 | type: CHANGE_ERASER_SIZE, 24 | brushSize: brushSize 25 | }; 26 | }; 27 | 28 | export { 29 | reducer as default, 30 | changeBrushSize 31 | }; 32 | -------------------------------------------------------------------------------- /src/reducers/eye-dropper.js: -------------------------------------------------------------------------------- 1 | const ACTIVATE_EYE_DROPPER = 'scratch-paint/eye-dropper/ACTIVATE_COLOR_PICKER'; 2 | const DEACTIVATE_EYE_DROPPER = 'scratch-paint/eye-dropper/DEACTIVATE_COLOR_PICKER'; 3 | 4 | const initialState = { 5 | active: false, 6 | callback: () => {}, // this will either be `onChangeFillColor` or `onChangeOutlineColor` 7 | previousTool: null // the tool that was previously active before eye dropper 8 | }; 9 | 10 | const reducer = function (state, action) { 11 | if (typeof state === 'undefined') state = initialState; 12 | switch (action.type) { 13 | case ACTIVATE_EYE_DROPPER: 14 | return Object.assign( 15 | {}, 16 | state, 17 | { 18 | active: true, 19 | callback: action.callback, 20 | previousTool: action.previousMode 21 | } 22 | ); 23 | case DEACTIVATE_EYE_DROPPER: 24 | return Object.assign( 25 | {}, 26 | state, 27 | { 28 | active: false, 29 | callback: () => {}, 30 | previousTool: null 31 | } 32 | ); 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | const activateEyeDropper = function (currentMode, callback) { 39 | return { 40 | type: ACTIVATE_EYE_DROPPER, 41 | callback: callback, 42 | previousMode: currentMode 43 | }; 44 | }; 45 | const deactivateEyeDropper = function () { 46 | return { 47 | type: DEACTIVATE_EYE_DROPPER 48 | }; 49 | }; 50 | 51 | export { 52 | reducer as default, 53 | activateEyeDropper, 54 | deactivateEyeDropper, 55 | ACTIVATE_EYE_DROPPER, 56 | DEACTIVATE_EYE_DROPPER 57 | }; 58 | -------------------------------------------------------------------------------- /src/reducers/fill-bitmap-shapes.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import {CHANGE_SELECTED_ITEMS} from './selected-items'; 3 | 4 | const SET_FILLED = 'scratch-paint/fill-bitmap-shapes/SET_FILLED'; 5 | const initialState = true; 6 | 7 | const reducer = function (state, action) { 8 | if (typeof state === 'undefined') state = initialState; 9 | switch (action.type) { 10 | case SET_FILLED: 11 | return action.filled; 12 | case CHANGE_SELECTED_ITEMS: 13 | if (action.bitmapMode && 14 | action.selectedItems && 15 | action.selectedItems[0] instanceof paper.Shape) { 16 | return action.selectedItems[0].strokeWidth === 0; 17 | } 18 | return state; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | // Action creators ================================== 25 | const setShapesFilled = function (filled) { 26 | return { 27 | type: SET_FILLED, 28 | filled: filled 29 | }; 30 | }; 31 | 32 | export { 33 | reducer as default, 34 | setShapesFilled 35 | }; 36 | -------------------------------------------------------------------------------- /src/reducers/fill-mode-gradient-type.js: -------------------------------------------------------------------------------- 1 | // Gradient type shown in the fill tool. This is the last gradient type explicitly chosen by the user, 2 | // and isn't overwritten by changing the selection. 3 | import GradientTypes from '../lib/gradient-types'; 4 | import log from '../log/log'; 5 | import {CHANGE_FILL_GRADIENT_TYPE} from './fill-style'; 6 | 7 | const initialState = null; 8 | 9 | const reducer = function (state, action) { 10 | if (typeof state === 'undefined') state = initialState; 11 | switch (action.type) { 12 | case CHANGE_FILL_GRADIENT_TYPE: 13 | if (action.gradientType in GradientTypes) { 14 | return action.gradientType; 15 | } 16 | log.warn(`Gradient type does not exist: ${action.gradientType}`); 17 | /* falls through */ 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | // Action creators ================================== 24 | // Use this for user-initiated gradient type selections only. 25 | // See reducers/fill-style.js for other ways gradient type changes. 26 | const changeGradientType = function (gradientType) { 27 | return { 28 | type: CHANGE_FILL_GRADIENT_TYPE, 29 | gradientType: gradientType 30 | }; 31 | }; 32 | 33 | export { 34 | reducer as default, 35 | changeGradientType 36 | }; 37 | -------------------------------------------------------------------------------- /src/reducers/fill-mode.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import fillModeGradientTypeReducer from './fill-mode-gradient-type'; 3 | import colorIndexReducer from './color-index'; 4 | 5 | export default combineReducers({ 6 | gradientType: fillModeGradientTypeReducer, 7 | colorIndex: colorIndexReducer 8 | }); 9 | -------------------------------------------------------------------------------- /src/reducers/fill-style.js: -------------------------------------------------------------------------------- 1 | import makeColorStyleReducer from '../lib/make-color-style-reducer'; 2 | 3 | const CHANGE_FILL_COLOR = 'scratch-paint/fill-style/CHANGE_FILL_COLOR'; 4 | const CHANGE_FILL_COLOR_2 = 'scratch-paint/fill-style/CHANGE_FILL_COLOR_2'; 5 | const CHANGE_FILL_GRADIENT_TYPE = 'scratch-paint/fill-style/CHANGE_FILL_GRADIENT_TYPE'; 6 | const CLEAR_FILL_GRADIENT = 'scratch-paint/fill-style/CLEAR_FILL_GRADIENT'; 7 | const DEFAULT_COLOR = '#9966FF'; 8 | 9 | const reducer = makeColorStyleReducer({ 10 | changePrimaryColorAction: CHANGE_FILL_COLOR, 11 | changeSecondaryColorAction: CHANGE_FILL_COLOR_2, 12 | changeGradientTypeAction: CHANGE_FILL_GRADIENT_TYPE, 13 | clearGradientAction: CLEAR_FILL_GRADIENT, 14 | defaultColor: DEFAULT_COLOR, 15 | selectionPrimaryColorKey: 'fillColor', 16 | selectionSecondaryColorKey: 'fillColor2', 17 | selectionGradientTypeKey: 'fillGradientType' 18 | }); 19 | 20 | // Action creators ================================== 21 | const changeFillColor = function (fillColor) { 22 | return { 23 | type: CHANGE_FILL_COLOR, 24 | color: fillColor 25 | }; 26 | }; 27 | 28 | const changeFillColor2 = function (fillColor) { 29 | return { 30 | type: CHANGE_FILL_COLOR_2, 31 | color: fillColor 32 | }; 33 | }; 34 | 35 | const changeFillGradientType = function (gradientType) { 36 | return { 37 | type: CHANGE_FILL_GRADIENT_TYPE, 38 | gradientType 39 | }; 40 | }; 41 | 42 | const clearFillGradient = function () { 43 | return { 44 | type: CLEAR_FILL_GRADIENT 45 | }; 46 | }; 47 | 48 | export { 49 | reducer as default, 50 | changeFillColor, 51 | changeFillColor2, 52 | changeFillGradientType, 53 | clearFillGradient, 54 | DEFAULT_COLOR, 55 | CHANGE_FILL_GRADIENT_TYPE 56 | }; 57 | -------------------------------------------------------------------------------- /src/reducers/font.js: -------------------------------------------------------------------------------- 1 | import Fonts from '../lib/fonts'; 2 | 3 | const CHANGE_FONT = 'scratch-paint/fonts/CHANGE_FONT'; 4 | const initialState = Fonts.SANS_SERIF; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_FONT: 10 | if (!action.font) return state; 11 | return action.font; 12 | default: 13 | return state; 14 | } 15 | }; 16 | 17 | // Action creators ================================== 18 | const changeFont = function (font) { 19 | return { 20 | type: CHANGE_FONT, 21 | font: font 22 | }; 23 | }; 24 | 25 | export { 26 | reducer as default, 27 | changeFont 28 | }; 29 | -------------------------------------------------------------------------------- /src/reducers/format.js: -------------------------------------------------------------------------------- 1 | import Formats from '../lib/format'; 2 | import log from '../log/log'; 3 | import {UNDO, REDO} from './undo'; 4 | 5 | const CHANGE_FORMAT = 'scratch-paint/formats/CHANGE_FORMAT'; 6 | const initialState = null; 7 | 8 | const reducer = function (state, action) { 9 | if (typeof state === 'undefined') state = initialState; 10 | switch (action.type) { 11 | case UNDO: 12 | /* falls through */ 13 | case REDO: 14 | /* falls through */ 15 | case CHANGE_FORMAT: 16 | if (!action.format) return state; 17 | if (action.format in Formats) { 18 | return action.format; 19 | } 20 | log.warn(`Format does not exist: ${action.format}`); 21 | /* falls through */ 22 | default: 23 | return state; 24 | } 25 | }; 26 | 27 | // Action creators ================================== 28 | const changeFormat = function (format) { 29 | return { 30 | type: CHANGE_FORMAT, 31 | format: format 32 | }; 33 | }; 34 | 35 | export { 36 | reducer as default, 37 | changeFormat 38 | }; 39 | -------------------------------------------------------------------------------- /src/reducers/hover.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const CHANGE_HOVERED = 'scratch-paint/hover/CHANGE_HOVERED'; 4 | const initialState = null; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_HOVERED: 10 | if (typeof action.hoveredItemId === 'undefined') { 11 | log.warn(`Hovered item should not be set to undefined. Use null.`); 12 | return state; 13 | } else if (typeof action.hoveredItemId === 'undefined' || isNaN(action.hoveredItemId)) { 14 | log.warn(`Hovered item should be an item ID number. Got: ${action.hoveredItemId}`); 15 | return state; 16 | } 17 | return action.hoveredItemId; 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | // Action creators ================================== 24 | /** 25 | * Set the hovered item state to the given item ID 26 | * @param {number} hoveredItemId The paper.Item ID of the hover indicator item. 27 | * @return {object} Redux action to change the hovered item. 28 | */ 29 | const setHoveredItem = function (hoveredItemId) { 30 | return { 31 | type: CHANGE_HOVERED, 32 | hoveredItemId: hoveredItemId 33 | }; 34 | }; 35 | 36 | const clearHoveredItem = function () { 37 | return { 38 | type: CHANGE_HOVERED, 39 | hoveredItemId: null 40 | }; 41 | }; 42 | 43 | export { 44 | reducer as default, 45 | setHoveredItem, 46 | clearHoveredItem 47 | }; 48 | -------------------------------------------------------------------------------- /src/reducers/layout.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | const SET_LAYOUT = 'scratch-paint/layout/SET_LAYOUT'; 3 | const initialState = {rtl: false}; 4 | 5 | const layouts = ['ltr', 'rtl']; 6 | 7 | const reducer = function (state, action) { 8 | if (typeof state === 'undefined') state = initialState; 9 | switch (action.type) { 10 | case SET_LAYOUT: 11 | if (layouts.indexOf(action.layout) === -1) { 12 | log.warn(`Unrecognized layout provided: ${action.layout}`); 13 | return state; 14 | } 15 | return {rtl: action.layout === 'rtl'}; 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | // Action creators ================================== 22 | /** 23 | * Change the layout to the new layout 24 | * @param {string} layout either 'ltr' or 'rtl' 25 | * @return {object} Redux action to change the selected items. 26 | */ 27 | const setLayout = function (layout) { 28 | return { 29 | type: SET_LAYOUT, 30 | layout: layout 31 | }; 32 | }; 33 | 34 | 35 | export { 36 | reducer as default, 37 | setLayout, 38 | SET_LAYOUT 39 | }; 40 | -------------------------------------------------------------------------------- /src/reducers/modals.js: -------------------------------------------------------------------------------- 1 | const OPEN_MODAL = 'scratch-paint/modals/OPEN_MODAL'; 2 | const CLOSE_MODAL = 'scratch-paint/modals/CLOSE_MODAL'; 3 | 4 | const MODAL_FILL_COLOR = 'fillColor'; 5 | const MODAL_STROKE_COLOR = 'strokeColor'; 6 | 7 | const initialState = { 8 | [MODAL_FILL_COLOR]: false, 9 | [MODAL_STROKE_COLOR]: false 10 | }; 11 | 12 | const reducer = function (state, action) { 13 | if (typeof state === 'undefined') state = initialState; 14 | switch (action.type) { 15 | case OPEN_MODAL: 16 | return Object.assign({}, initialState, { 17 | [action.modal]: true 18 | }); 19 | case CLOSE_MODAL: 20 | return Object.assign({}, initialState, { 21 | [action.modal]: false 22 | }); 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | const openModal = function (modal) { 29 | return { 30 | type: OPEN_MODAL, 31 | modal: modal 32 | }; 33 | }; 34 | 35 | const closeModal = function (modal) { 36 | return { 37 | type: CLOSE_MODAL, 38 | modal: modal 39 | }; 40 | }; 41 | 42 | // Action creators ================================== 43 | 44 | const openFillColor = function () { 45 | return openModal(MODAL_FILL_COLOR); 46 | }; 47 | 48 | const openStrokeColor = function () { 49 | return openModal(MODAL_STROKE_COLOR); 50 | }; 51 | 52 | const closeFillColor = function () { 53 | return closeModal(MODAL_FILL_COLOR); 54 | }; 55 | 56 | const closeStrokeColor = function () { 57 | return closeModal(MODAL_STROKE_COLOR); 58 | }; 59 | 60 | export { 61 | reducer as default, 62 | openFillColor, 63 | openStrokeColor, 64 | closeFillColor, 65 | closeStrokeColor 66 | }; 67 | -------------------------------------------------------------------------------- /src/reducers/modes.js: -------------------------------------------------------------------------------- 1 | import Modes from '../lib/modes'; 2 | import log from '../log/log'; 3 | 4 | const CHANGE_MODE = 'scratch-paint/modes/CHANGE_MODE'; 5 | const initialState = Modes.SELECT; 6 | 7 | const reducer = function (state, action) { 8 | if (typeof state === 'undefined') state = initialState; 9 | switch (action.type) { 10 | case CHANGE_MODE: 11 | if (action.mode in Modes) { 12 | return action.mode; 13 | } 14 | log.warn(`Mode does not exist: ${action.mode}`); 15 | /* falls through */ 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | // Action creators ================================== 22 | const changeMode = function (mode) { 23 | return { 24 | type: CHANGE_MODE, 25 | mode: mode 26 | }; 27 | }; 28 | 29 | export { 30 | reducer as default, 31 | changeMode 32 | }; 33 | -------------------------------------------------------------------------------- /src/reducers/scratch-paint-reducer.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import modeReducer from './modes'; 3 | import bitBrushSizeReducer from './bit-brush-size'; 4 | import bitEraserSizeReducer from './bit-eraser-size'; 5 | import brushModeReducer from './brush-mode'; 6 | import eraserModeReducer from './eraser-mode'; 7 | import colorReducer from './color'; 8 | import clipboardReducer from './clipboard'; 9 | import cursorReducer from './cursor'; 10 | import fillBitmapShapesReducer from './fill-bitmap-shapes'; 11 | import fillModeReducer from './fill-mode'; 12 | import fontReducer from './font'; 13 | import formatReducer from './format'; 14 | import hoverReducer from './hover'; 15 | import layoutReducer from './layout'; 16 | import modalsReducer from './modals'; 17 | import selectedItemReducer from './selected-items'; 18 | import textEditTargetReducer from './text-edit-target'; 19 | import viewBoundsReducer from './view-bounds'; 20 | import undoReducer from './undo'; 21 | import zoomLevelsReducer from './zoom-levels'; 22 | 23 | export default combineReducers({ 24 | mode: modeReducer, 25 | bitBrushSize: bitBrushSizeReducer, 26 | bitEraserSize: bitEraserSizeReducer, 27 | brushMode: brushModeReducer, 28 | color: colorReducer, 29 | clipboard: clipboardReducer, 30 | cursor: cursorReducer, 31 | eraserMode: eraserModeReducer, 32 | fillBitmapShapes: fillBitmapShapesReducer, 33 | fillMode: fillModeReducer, 34 | font: fontReducer, 35 | format: formatReducer, 36 | hoveredItemId: hoverReducer, 37 | layout: layoutReducer, 38 | modals: modalsReducer, 39 | selectedItems: selectedItemReducer, 40 | textEditTarget: textEditTargetReducer, 41 | undo: undoReducer, 42 | viewBounds: viewBoundsReducer, 43 | zoomLevels: zoomLevelsReducer 44 | }); 45 | -------------------------------------------------------------------------------- /src/reducers/selected-items.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | const CHANGE_SELECTED_ITEMS = 'scratch-paint/select/CHANGE_SELECTED_ITEMS'; 3 | const REDRAW_SELECTION_BOX = 'scratch-paint/select/REDRAW_SELECTION_BOX'; 4 | const initialState = []; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case REDRAW_SELECTION_BOX: 10 | if (state.length > 0) return state.slice(0); // Sends an update even though the items haven't changed 11 | return state; 12 | case CHANGE_SELECTED_ITEMS: 13 | if (!action.selectedItems || !(action.selectedItems instanceof Array)) { 14 | log.warn(`No selected items or wrong format provided: ${action.selectedItems}`); 15 | return state; 16 | } 17 | if (action.selectedItems.length > 1 && action.bitmapMode) { 18 | log.warn(`Multiselect should not be possible in bitmap mode: ${action.selectedItems}`); 19 | return state; 20 | } 21 | // If they are both empty, no change 22 | if (action.selectedItems.length === 0 && state.length === 0) { 23 | return state; 24 | } 25 | return action.selectedItems; 26 | default: 27 | return state; 28 | } 29 | }; 30 | 31 | // Action creators ================================== 32 | /** 33 | * Set the selected item state to the given array of items 34 | * @param {Array} selectedItems from paper.project.selectedItems 35 | * @param {?boolean} bitmapMode True if the items are being selected in bitmap mode 36 | * @return {object} Redux action to change the selected items. 37 | */ 38 | const setSelectedItems = function (selectedItems, bitmapMode) { 39 | return { 40 | type: CHANGE_SELECTED_ITEMS, 41 | selectedItems: selectedItems, 42 | bitmapMode: bitmapMode 43 | }; 44 | }; 45 | const clearSelectedItems = function () { 46 | return { 47 | type: CHANGE_SELECTED_ITEMS, 48 | selectedItems: [] 49 | }; 50 | }; 51 | const redrawSelectionBox = function () { 52 | return { 53 | type: REDRAW_SELECTION_BOX 54 | }; 55 | }; 56 | 57 | export { 58 | reducer as default, 59 | redrawSelectionBox, 60 | setSelectedItems, 61 | clearSelectedItems, 62 | CHANGE_SELECTED_ITEMS 63 | }; 64 | -------------------------------------------------------------------------------- /src/reducers/stroke-width.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | import {CHANGE_SELECTED_ITEMS} from './selected-items'; 3 | import {getColorsFromSelection} from '../helper/style-path'; 4 | 5 | const CHANGE_STROKE_WIDTH = 'scratch-paint/stroke-width/CHANGE_STROKE_WIDTH'; 6 | const MAX_STROKE_WIDTH = 100; 7 | const initialState = 4; 8 | 9 | const reducer = function (state, action) { 10 | if (typeof state === 'undefined') state = initialState; 11 | switch (action.type) { 12 | case CHANGE_STROKE_WIDTH: 13 | if (isNaN(action.strokeWidth)) { 14 | log.warn(`Invalid brush size: ${action.strokeWidth}`); 15 | return state; 16 | } 17 | return Math.min(MAX_STROKE_WIDTH, Math.max(0, action.strokeWidth)); 18 | case CHANGE_SELECTED_ITEMS: 19 | // Don't change state if no selection 20 | if (!action.selectedItems || !action.selectedItems.length) { 21 | return state; 22 | } 23 | // Bitmap mode doesn't have stroke width 24 | if (action.bitmapMode) { 25 | return state; 26 | } 27 | return getColorsFromSelection(action.selectedItems, action.bitmapMode).strokeWidth; 28 | default: 29 | return state; 30 | } 31 | }; 32 | 33 | // Action creators ================================== 34 | const changeStrokeWidth = function (strokeWidth) { 35 | return { 36 | type: CHANGE_STROKE_WIDTH, 37 | strokeWidth: strokeWidth 38 | }; 39 | }; 40 | 41 | export { 42 | reducer as default, 43 | changeStrokeWidth, 44 | CHANGE_STROKE_WIDTH, 45 | MAX_STROKE_WIDTH 46 | }; 47 | -------------------------------------------------------------------------------- /src/reducers/text-edit-target.js: -------------------------------------------------------------------------------- 1 | import log from '../log/log'; 2 | 3 | const CHANGE_TEXT_EDIT_TARGET = 'scratch-paint/text-tool/CHANGE_TEXT_EDIT_TARGET'; 4 | const initialState = null; 5 | 6 | const reducer = function (state, action) { 7 | if (typeof state === 'undefined') state = initialState; 8 | switch (action.type) { 9 | case CHANGE_TEXT_EDIT_TARGET: 10 | if (typeof action.textEditTargetId === 'undefined') { 11 | log.warn(`Text edit target should not be set to undefined. Use null.`); 12 | return state; 13 | } else if (typeof action.textEditTargetId === 'undefined' || isNaN(action.textEditTargetId)) { 14 | log.warn(`Text edit target should be an item ID number. Got: ${action.textEditTargetId}`); 15 | return state; 16 | } 17 | return action.textEditTargetId; 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | // Action creators ================================== 24 | /** 25 | * Set the currently-being-edited text field to the given item ID 26 | * @param {?number} textEditTargetId The paper.Item ID of the active text field. 27 | * Leave empty if there is no text editing target. 28 | * @return {object} Redux action to change the text edit target. 29 | */ 30 | const setTextEditTarget = function (textEditTargetId) { 31 | return { 32 | type: CHANGE_TEXT_EDIT_TARGET, 33 | textEditTargetId: textEditTargetId ? textEditTargetId : null 34 | }; 35 | }; 36 | 37 | export { 38 | reducer as default, 39 | setTextEditTarget 40 | }; 41 | -------------------------------------------------------------------------------- /src/reducers/view-bounds.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import log from '../log/log'; 3 | 4 | const UPDATE_VIEW_BOUNDS = 'scratch-paint/view/UPDATE_VIEW_BOUNDS'; 5 | const initialState = new paper.Matrix(); // Identity 6 | 7 | const reducer = function (state, action) { 8 | if (typeof state === 'undefined') state = initialState; 9 | switch (action.type) { 10 | case UPDATE_VIEW_BOUNDS: 11 | if (!(action.viewBounds instanceof paper.Matrix)) { 12 | log.warn(`View bounds should be a paper.Matrix.`); 13 | return state; 14 | } 15 | return action.viewBounds; 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | // Action creators ================================== 22 | /** 23 | * Set the view bounds, which defines the zoom and scroll of the paper canvas. 24 | * @param {paper.Matrix} matrix The matrix applied to the view 25 | * @return {object} Redux action to set the view bounds 26 | */ 27 | const updateViewBounds = function (matrix) { 28 | return { 29 | type: UPDATE_VIEW_BOUNDS, 30 | viewBounds: matrix.clone() 31 | }; 32 | }; 33 | 34 | export { 35 | reducer as default, 36 | updateViewBounds 37 | }; 38 | -------------------------------------------------------------------------------- /src/reducers/zoom-levels.js: -------------------------------------------------------------------------------- 1 | import paper from '@scratch/paper'; 2 | import log from '../log/log'; 3 | 4 | const SAVE_ZOOM_LEVEL = 'scratch-paint/zoom-levels/SAVE_ZOOM_LEVEL'; 5 | const SET_ZOOM_LEVEL_ID = 'scratch-paint/zoom-levels/SET_ZOOM_LEVEL_ID'; 6 | const initialState = {}; 7 | 8 | const reducer = function (state, action) { 9 | if (typeof state === 'undefined') state = initialState; 10 | switch (action.type) { 11 | case SET_ZOOM_LEVEL_ID: 12 | if (action.zoomLevelId === 'currentZoomLevelId') { 13 | log.warn(`currentZoomLevelId is an invalid string for zoomLevel`); 14 | return state; 15 | } 16 | return Object.assign({}, state, { 17 | currentZoomLevelId: action.zoomLevelId 18 | }); 19 | case SAVE_ZOOM_LEVEL: 20 | return Object.assign({}, state, { 21 | [state.currentZoomLevelId]: action.zoomLevel 22 | }); 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | // Action creators ================================== 29 | const saveZoomLevel = function (zoomLevel) { 30 | if (!(zoomLevel instanceof paper.Matrix)) { 31 | log.warn(`Not a matrix: ${zoomLevel}`); 32 | } 33 | return { 34 | type: SAVE_ZOOM_LEVEL, 35 | zoomLevel: new paper.Matrix(zoomLevel) 36 | }; 37 | }; 38 | const setZoomLevelId = function (zoomLevelId) { 39 | return { 40 | type: SET_ZOOM_LEVEL_ID, 41 | zoomLevelId: zoomLevelId 42 | }; 43 | }; 44 | 45 | export { 46 | reducer as default, 47 | saveZoomLevel, 48 | setZoomLevelId 49 | }; 50 | -------------------------------------------------------------------------------- /test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/fileMock.js 2 | 3 | module.exports = 'test-file-stub'; 4 | -------------------------------------------------------------------------------- /test/__mocks__/paperMocks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pretend paper.Item whose parent is a layer. 3 | * @param {object} options Item params 4 | * @param {string} options.strokeColor Value to return for the item's stroke color 5 | * @param {string} options.fillColor Value to return for the item's fill color 6 | * @param {string} options.strokeWidth Value to return for the item's stroke width 7 | * @return {object} mock item 8 | */ 9 | const mockPaperRootItem = function (options) { 10 | return { 11 | strokeColor: {toCSS: function () { 12 | return options.strokeColor; 13 | }}, 14 | fillColor: {toCSS: function () { 15 | return options.fillColor; 16 | }}, 17 | strokeWidth: options.strokeWidth, 18 | parent: {className: 'Layer'}, 19 | data: {} 20 | }; 21 | }; 22 | 23 | export {mockPaperRootItem}; 24 | -------------------------------------------------------------------------------- /test/__mocks__/react-intl.js: -------------------------------------------------------------------------------- 1 | // __mocks__/react-intl.js 2 | 3 | import React from 'react'; // eslint-disable-line no-unused-vars 4 | const Intl = require.requireActual('react-intl'); 5 | 6 | // Here goes intl context injected into component, feel free to extend 7 | const intl = { 8 | formatMessage: ({defaultMessage}) => defaultMessage, 9 | formatDate: ({defaultMessage}) => defaultMessage, 10 | formatTime: ({defaultMessage}) => defaultMessage, 11 | formatRelative: ({defaultMessage}) => defaultMessage, 12 | formatNumber: ({defaultMessage}) => defaultMessage, 13 | formatPlural: ({defaultMessage}) => defaultMessage, 14 | formatHTMLMessage: ({defaultMessage}) => defaultMessage, 15 | now: () => 0 16 | }; 17 | 18 | Intl.injectIntl = Node => { 19 | const renderWrapped = props => ; 20 | renderWrapped.displayName = Node.displayName || 21 | Node.name || 22 | 'Component'; 23 | return renderWrapped; 24 | }; 25 | 26 | module.exports = Intl; 27 | -------------------------------------------------------------------------------- /test/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/styleMock.js 2 | 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /test/helpers/enzyme-setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({adapter: new Adapter()}); 5 | -------------------------------------------------------------------------------- /test/unit/blob-mode-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import brushReducer, {changeBrushSize} from '../../src/reducers/brush-mode'; 3 | import eraserReducer, {changeBrushSize as changeEraserSize} from '../../src/reducers/eraser-mode'; 4 | 5 | test('initialState', () => { 6 | let defaultState; 7 | 8 | expect(brushReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); 9 | expect(brushReducer(defaultState /* state */, {type: 'anything'} /* action */).brushSize).toBeGreaterThan(0); 10 | 11 | expect(eraserReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeTruthy(); 12 | expect(eraserReducer(defaultState /* state */, {type: 'anything'} /* action */).brushSize).toBeGreaterThan(0); 13 | }); 14 | 15 | test('changeBrushSize', () => { 16 | let defaultState; 17 | 18 | const newBrushSize = 8078; 19 | 20 | expect(brushReducer(defaultState /* state */, changeBrushSize(newBrushSize) /* action */)) 21 | .toEqual({brushSize: newBrushSize}); 22 | expect(brushReducer(1 /* state */, changeBrushSize(newBrushSize) /* action */)) 23 | .toEqual({brushSize: newBrushSize}); 24 | 25 | expect(eraserReducer(defaultState /* state */, changeEraserSize(newBrushSize) /* action */)) 26 | .toEqual({brushSize: newBrushSize}); 27 | expect(eraserReducer(1 /* state */, changeEraserSize(newBrushSize) /* action */)) 28 | .toEqual({brushSize: newBrushSize}); 29 | }); 30 | 31 | test('invalidChangeBrushSize', () => { 32 | const origState = {brushSize: 1}; 33 | 34 | expect(brushReducer(origState /* state */, changeBrushSize('invalid argument') /* action */)) 35 | .toBe(origState); 36 | expect(brushReducer(origState /* state */, changeBrushSize() /* action */)) 37 | .toBe(origState); 38 | 39 | expect(eraserReducer(origState /* state */, changeEraserSize('invalid argument') /* action */)) 40 | .toBe(origState); 41 | expect(eraserReducer(origState /* state */, changeEraserSize() /* action */)) 42 | .toBe(origState); 43 | }); 44 | -------------------------------------------------------------------------------- /test/unit/clipboard-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import clipboardReducer, { 3 | clearPasteOffset, incrementPasteOffset, setClipboardItems 4 | } from '../../src/reducers/clipboard'; 5 | 6 | test('initialState', () => { 7 | let defaultState; 8 | 9 | expect(clipboardReducer(defaultState /* state */, {type: 'anything'} /* action */).items).toBeDefined(); 10 | expect(clipboardReducer(defaultState /* state */, {type: 'anything'} /* action */).pasteOffset).toBeDefined(); 11 | }); 12 | 13 | test('setClipboardItems', () => { 14 | let defaultState; 15 | 16 | const newSelected1 = ['selected1', 'selected2']; 17 | const newSelected2 = ['selected1', 'selected3']; 18 | expect(clipboardReducer(defaultState /* state */, setClipboardItems(newSelected1) /* action */).items) 19 | .toEqual(newSelected1); 20 | expect(clipboardReducer(defaultState /* state */, setClipboardItems(newSelected1) /* action */).pasteOffset) 21 | .toEqual(1); 22 | expect(clipboardReducer(newSelected1, setClipboardItems(newSelected2) /* action */).items) 23 | .toEqual(newSelected2); 24 | expect(clipboardReducer(defaultState /* state */, setClipboardItems(newSelected1) /* action */).pasteOffset) 25 | .toEqual(1); 26 | }); 27 | 28 | test('incrementPasteOffset', () => { 29 | const origState = { 30 | items: ['selected1', 'selected2'], 31 | pasteOffset: 1 32 | }; 33 | 34 | expect(clipboardReducer(origState /* state */, incrementPasteOffset() /* action */).pasteOffset) 35 | .toEqual(2); 36 | expect(clipboardReducer(origState, incrementPasteOffset() /* action */).items) 37 | .toEqual(origState.items); 38 | }); 39 | 40 | test('clearPasteOffset', () => { 41 | const origState = { 42 | items: ['selected1', 'selected2'], 43 | pasteOffset: 1 44 | }; 45 | 46 | expect(clipboardReducer(origState /* state */, clearPasteOffset() /* action */).pasteOffset) 47 | .toEqual(0); 48 | expect(clipboardReducer(origState, clearPasteOffset() /* action */).items) 49 | .toEqual(origState.items); 50 | }); 51 | 52 | test('invalidSetClipboardItems', () => { 53 | const origState = { 54 | items: ['selected1', 'selected2'], 55 | pasteOffset: 1 56 | }; 57 | const nothingSelected = []; 58 | 59 | expect(clipboardReducer(origState /* state */, setClipboardItems() /* action */)) 60 | .toBe(origState); 61 | expect(clipboardReducer(origState /* state */, setClipboardItems('notAnArray') /* action */)) 62 | .toBe(origState); 63 | expect(clipboardReducer(origState /* state */, setClipboardItems(nothingSelected) /* action */)) 64 | .toBe(origState); 65 | }); 66 | -------------------------------------------------------------------------------- /test/unit/components/button-click.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react'; // eslint-disable-line no-unused-vars 3 | import {shallow} from 'enzyme'; 4 | import Button from '../../../src/components/button/button.jsx'; // eslint-disable-line no-unused-vars, max-len 5 | 6 | describe('Button', () => { 7 | test('triggers callback when clicked', () => { 8 | const onClick = jest.fn(); 9 | const componentShallowWrapper = shallow( 10 | 13 | ); 14 | componentShallowWrapper.simulate('click'); 15 | expect(onClick).toHaveBeenCalled(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/unit/format-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import Formats from '../../src/lib/format'; 3 | import reducer, {changeFormat} from '../../src/reducers/format'; 4 | import {undo, redo} from '../../src/reducers/undo'; 5 | 6 | test('initialState', () => { 7 | let defaultState; 8 | expect(reducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeNull(); 9 | }); 10 | 11 | test('changeFormat', () => { 12 | let defaultState; 13 | expect(reducer(defaultState /* state */, changeFormat(Formats.BITMAP) /* action */)).toBe(Formats.BITMAP); 14 | expect(reducer(Formats.BITMAP /* state */, changeFormat(Formats.BITMAP) /* action */)) 15 | .toBe(Formats.BITMAP); 16 | expect(reducer(Formats.BITMAP /* state */, changeFormat(Formats.VECTOR) /* action */)) 17 | .toBe(Formats.VECTOR); 18 | }); 19 | 20 | test('undoRedoChangeFormat', () => { 21 | let defaultState; 22 | let reduxState = reducer(defaultState /* state */, changeFormat(Formats.BITMAP) /* action */); 23 | expect(reduxState).toBe(Formats.BITMAP); 24 | reduxState = reducer(reduxState /* state */, undo(Formats.BITMAP_SKIP_CONVERT) /* action */); 25 | expect(reduxState).toBe(Formats.BITMAP_SKIP_CONVERT); 26 | reduxState = reducer(reduxState /* state */, redo(Formats.VECTOR_SKIP_CONVERT) /* action */); 27 | expect(reduxState).toBe(Formats.VECTOR_SKIP_CONVERT); 28 | }); 29 | 30 | test('invalidChangeMode', () => { 31 | expect(reducer(Formats.BITMAP /* state */, changeFormat('non-existant mode') /* action */)) 32 | .toBe(Formats.BITMAP); 33 | expect(reducer(Formats.BITMAP /* state */, changeFormat() /* action */)).toBe(Formats.BITMAP); 34 | }); 35 | -------------------------------------------------------------------------------- /test/unit/hover-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import reducer, {clearHoveredItem, setHoveredItem} from '../../src/reducers/hover'; 3 | 4 | test('initialState', () => { 5 | let defaultState; 6 | expect(reducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeNull(); 7 | }); 8 | 9 | test('setHoveredItem', () => { 10 | let defaultState; 11 | const item1 = 1; 12 | const item2 = 2; 13 | expect(reducer(defaultState /* state */, setHoveredItem(item1) /* action */)).toBe(item1); 14 | expect(reducer(item1 /* state */, setHoveredItem(item2) /* action */)).toBe(item2); 15 | }); 16 | 17 | test('clearHoveredItem', () => { 18 | let defaultState; 19 | const item = 1; 20 | expect(reducer(defaultState /* state */, clearHoveredItem() /* action */)).toBeNull(); 21 | expect(reducer(item /* state */, clearHoveredItem() /* action */)).toBeNull(); 22 | }); 23 | 24 | test('invalidSetHoveredItem', () => { 25 | let defaultState; 26 | const item = 1; 27 | const nonItem = {random: 'object'}; 28 | let undef; 29 | expect(reducer(defaultState /* state */, setHoveredItem(nonItem) /* action */)).toBeNull(); 30 | expect(reducer(item /* state */, setHoveredItem(nonItem) /* action */)) 31 | .toBe(item); 32 | expect(reducer(item /* state */, setHoveredItem(undef) /* action */)) 33 | .toBe(item); 34 | }); 35 | -------------------------------------------------------------------------------- /test/unit/modes-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import Modes from '../../src/lib/modes'; 3 | import reducer, {changeMode} from '../../src/reducers/modes'; 4 | 5 | test('initialState', () => { 6 | let defaultState; 7 | expect(reducer(defaultState /* state */, {type: 'anything'} /* action */) in Modes).toBeTruthy(); 8 | }); 9 | 10 | test('changeMode', () => { 11 | let defaultState; 12 | expect(reducer(defaultState /* state */, changeMode(Modes.ERASER) /* action */)).toBe(Modes.ERASER); 13 | expect(reducer(Modes.ERASER /* state */, changeMode(Modes.ERASER) /* action */)) 14 | .toBe(Modes.ERASER); 15 | expect(reducer(Modes.BRUSH /* state */, changeMode(Modes.ERASER) /* action */)) 16 | .toBe(Modes.ERASER); 17 | }); 18 | 19 | test('invalidChangeMode', () => { 20 | expect(reducer(Modes.BRUSH /* state */, changeMode('non-existant mode') /* action */)) 21 | .toBe(Modes.BRUSH); 22 | expect(reducer(Modes.BRUSH /* state */, changeMode() /* action */)).toBe(Modes.BRUSH); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/selected-items-reducer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import selectedItemsReducer, { 3 | setSelectedItems, clearSelectedItems 4 | } from '../../src/reducers/selected-items'; 5 | 6 | test('initialState', () => { 7 | let defaultState; 8 | 9 | expect(selectedItemsReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); 10 | }); 11 | 12 | test('setSelectedItems', () => { 13 | let defaultState; 14 | 15 | const newSelected1 = ['selected1', 'selected2']; 16 | const newSelected2 = ['selected1', 'selected3']; 17 | const unselected = []; 18 | expect(selectedItemsReducer(defaultState /* state */, setSelectedItems(newSelected1) /* action */)) 19 | .toEqual(newSelected1); 20 | expect(selectedItemsReducer(newSelected1, setSelectedItems(newSelected2) /* action */)) 21 | .toEqual(newSelected2); 22 | expect(selectedItemsReducer(newSelected1, setSelectedItems(unselected) /* action */)) 23 | .toEqual(unselected); 24 | expect(selectedItemsReducer(defaultState, setSelectedItems(unselected) /* action */)) 25 | .toEqual(unselected); 26 | }); 27 | 28 | test('clearSelectedItems', () => { 29 | let defaultState; 30 | 31 | const selectedState = ['selected1', 'selected2']; 32 | const unselectedState = []; 33 | expect(selectedItemsReducer(defaultState /* state */, clearSelectedItems() /* action */)) 34 | .toHaveLength(0); 35 | expect(selectedItemsReducer(selectedState /* state */, clearSelectedItems() /* action */)) 36 | .toHaveLength(0); 37 | expect(selectedItemsReducer(unselectedState /* state */, clearSelectedItems() /* action */)) 38 | .toHaveLength(0); 39 | }); 40 | 41 | test('invalidsetSelectedItems', () => { 42 | const origState = ['selected1', 'selected2']; 43 | 44 | expect(selectedItemsReducer(origState /* state */, setSelectedItems() /* action */)) 45 | .toBe(origState); 46 | expect(selectedItemsReducer(origState /* state */, setSelectedItems('notAnArray') /* action */)) 47 | .toBe(origState); 48 | }); 49 | -------------------------------------------------------------------------------- /tmp.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-paint/f0f9e95bbf130d6a7e644ed1e8f02a93b5ad9481/tmp.js --------------------------------------------------------------------------------